// v15.10.235-UAT: full replay-integrity repair: player-natural settlement no longer consumes synthetic dealer draws; Ultimate/Stat-Watching use corrected trace evidence; Ultimate compares against canonical observed Frozen; navigation continues to the longer journey; comparator labels are target-specific.
import javax.swing.*;
import javax.swing.border.*;
import javax.swing.table.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.AffineTransform;
import java.awt.geom.RoundRectangle2D;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.List;
import java.util.regex.*;

    // v15.10.129-UAT: Frozen-vs-Fixed-Policy-Casual replay now uses the validated formal-start Frozen reconstruction whenever Hybrid formal-start evidence exists, so both sides share the same formal captured source and reconcile with retained Session evidence. Dropdown terminology is now explicit Fixed-Policy Casual. No controller policy or settlement logic changed.
// v15.10.192: split-only vertical repair. Dealer cards retain full height; the unused normal-hand
// player-outcome lane is removed during split playback; split child cards always use the existing
// compact renderer so child verdicts have permanent space below. Normal-hand geometry is unchanged.
// v15.10.233-UAT: post-bust observed-source cards are excluded from dealer-play rendering but remain counted in the physical source pointer; this preserves provenance and prevents false pointer reset. Adds explicit source-consumed annotation and observed action symmetry for bust hands.
// v15.10.231-UAT: completed historical sessions can consume append-only controller-trace backfill sidecars; no S rerun or mutation of original evidence is required.
// v15.10.229-UAT: redundant-Hybrid suppression. Hybrid receives a Replay comparison slot only when its retained observed journey diverges from Frozen in wager and/or a supported card decision. A Hybrid journey that exactly follows Frozen no longer consumes a right-hand slot; Fixed-Policy Casual becomes the ordinary comparator and the selective exceptional slot remains available. Frozen stays the invariant baseline; maximum two right-hand targets; ±£50 extreme gate unchanged.
// v15.10.227-UAT: selective controller replay gate. Frozen remains the invariant baseline; at most two comparison targets are exposed. Hybrid is retained when actually played. Otherwise Fixed-Policy Casual is retained as the ordinary comparator. A second Ultimate AI or Stat-Watching Casual target is exposed only when its source-complete final bankroll is >= £50 beyond the highest, or <= £50 below the lowest, of the other valid controller outcomes. Evidence-only bankroll-path replay is used for the exploratory controllers; no cards/actions are inferred.
public class BlackjackSessionReplay extends JFrame {
    // v15.10.180-UAT: preserve committed round exposure after SPLIT/DOUBLE. The recurring
    // backend "Wager: MATCH reference" transcript marker is base-wager authority only and
    // must not collapse an already-committed split/double exposure back to the initial stake.
    // v15.10.333-UAT: Replay vertical-fit correction: comparison controls, source action and same-state orientation share one compact second toolbar row, returning vertical space to the replay body while preserving the expanded session selector and full transport lane. No replay/evidence/settlement/controller logic changed.
    // v15.10.332-UAT: Replay toolbar fit correction: session selector remains expanded but is capped to preserve the full Prev/Next/Play/Pause transport lane on Windows. Compare controls remain on their dedicated row. No replay/evidence/settlement/controller logic changed.
    // v15.10.331-UAT: Replay toolbar restoration: Compare Journeys gets a dedicated full-width row so controls cannot be clipped; standalone preamble selector uses retained mode when platform provenance is unknown. No replay/evidence/settlement/controller logic changed.
    // v15.10.217: Hybrid Player Notes presentation repair. All recorded note categories now word-wrap to the available replay width and the notes host expands vertically to the rendered content, preserving complete original evidence text without shrinking cards. No evidence/category/timing/replay/controller calculations changed.
    // v15.10.216: session-scoped retained-evidence authority repair. Session-specific correction evidence is loaded only for its own session, and corrected formal-start validation is scoped to the requested SESSION_ID. Prevents Session 13 corrected expectations contaminating Session 10/11/12 comparison gates. No evidence, card stream, settlement, controller or Live Play logic changed.
    // v15.10.215: observed-hand split authority repair. Detailed audit parsing now marks an observed hand as split only when the ACTUAL action is SPLIT; a Frozen-policy SPLIT recommendation alone must not convert a Hybrid STAND/HIT/DOUBLE into a split. Session 13 H11 regression: observed 9C,9S vs 9D remains STAND/18/LOSS with exactly four recorded cards, while independently reconstructed Frozen may split in Compare mode. No evidence, card stream, settlement, controller or Live Play logic changed.
    // v15.10.196: final vertical-layout containment repair. The DEALER/PLAYER headers,
    // centre action message and normal player-outcome row now have explicit fixed-height
    // BoxLayout footprints. Previously those lightweight Swing rows had effectively unlimited
    // maximum height, so a maximised replay window distributed spare felt into invisible space
    // above/between the card rows and pushed the player/split cards against the screen bottom.
    // Full 92x132 normal cards are retained. Split children remain compact, with their child
    // verdicts below. The bottom normal WIN/LOSS/PUSH overlay remains reserved. Presentation only.
    // v15.10.193: presentation-only vertical geometry repair. Dealer/player card rows now
    // start at the top of their lanes (zero FlowLayout vertical gap) and each normal card
    // lane is exactly the established 132px CardPanel height. This uses the previously
    // wasted space above the dealer cards, prevents bottom clipping, lifts the following
    // table content, and preserves the bottom settlement overlay. No animation sequence,
    // cards/evidence, outcomes, bankroll/wager, right panel, H/N/L, controller or settlement changed.

    // v15.10.189: final GUI fit: enlarge Recent Player Experience strip so its fourth line is never clipped, and move only the normal-hand WIN/LOSS/PUSH closer to the player cards. All other geometry/logic unchanged.
    // v15.10.185: freeze Recent Player Experience strip height during every animation stage; reduce/lift only the normal-hand contextual verdict. Split Hand 22/right panel/evidence/logic unchanged.
    // v15.10.182: final normal-hand vertical-fit polish. The felt top inset is reduced by 10px and the normal-hand casino chip footer by 30px, lifting the complete table roughly 40px while keeping the settled verdict directly beneath the player area. Split child verdict logic/layout, right-hand panel, evidence, card parsing/order, bankroll, wager, controller and settlement are unchanged.
    // v15.10.180: final normal-hand vertical-fit correction. The established two-line summary remains intact; normal dealer/player lanes and the normal-hand chip footer are compacted slightly and a fixed 38px bottom verdict lane is reserved so the settled WIN/LOSS/PUSH result remains visible. Split playback geometry from v15.10.180, the entire right-hand panel, Compare Mode, evidence, matching, H/N/L/corpus calculations, blackjack/controller logic, card order and settlement are unchanged.
    // v15.10.177: split child verdict timing correction. WIN/LOSS/PUSH beneath Split A/B is now gated strictly to the settlement stage after the dealer has fully resolved. The split-animation stage uses numeric stage 40, so the earlier stage>=6 presentation gate incorrectly treated an in-progress split as settled and could derive a verdict from partial child cards. Presentation timing only; recorded cards, recorded child outcomes, dealer resolution, bankroll, settlement, right panel, Compare Mode and blackjack/controller logic are unchanged.
    // v15.10.176: final standalone split-layout UAT repair. Split playback hides the casino chip footer completely and reserves sufficient split-child panel height for the recorded WIN/LOSS/PUSH verdict beneath each child. Right panel, Compare Mode, evidence, matching, H/N/L/corpus calculations and blackjack/controller/settlement logic are unchanged.
    // v15.10.176: final Replay GUI UAT polish. The dynamic 80,000-path matching-occurrence explanation in SAME-STATE ROBUSTNESS is promoted to the same readable visual weight as the matching-occurrence summary. Presentation-only; right-panel geometry, split layout/verdict behaviour, Compare Mode, evidence, matching, H/N/L/corpus calculations and blackjack/controller/settlement logic are unchanged.
    // v15.10.174: standalone split presentation UAT refinement. Both split children now use one consistent compact card footprint whenever either child needs compact rendering; each child verdict is shown beneath its own child hand only after settlement; the overall split verdict is shown once above the split-hand area and is derived from the recorded bankroll transition (positive WIN, negative LOSS, unchanged PUSH). Right-hand panel geometry from v15.10.170 is unchanged. Compare Mode and all recorded evidence, blackjack/controller/settlement logic, corpus calculations, matching semantics and wagers are unchanged.
    // v15.10.164: H/N/L same-state corpus GUI semantics tightened to the CURRENT OBSERVED SESSION robustness family. The original paired replicate IDs remain provenance only; every Frozen and Casual path is independently eligible for same-state H/N/L analysis. Corpus filename is resolved from the current session ID so Session 11 corpus evidence cannot appear under another observed session. No blackjack/controller/cardstream/settlement/matching logic changed.
    // v15.10.161: zero-occurrence evidence visibility correction. When the current starting state has no prior matched occurrence, Replay stops after the explicit no-occurrence message and suppresses SAME-STATE HISTORY and the global A-E corpus reference. Real-time current-hand H/N/L remains visible. Presentation/evidence-gating only; no corpus values, matching, replay, cardstream, controller, wager, settlement, or blackjack logic changed.
    // v15.10.158: H/N/L corpus panel final-fit housekeeping. Average-additional-card and manifestation statistics are moved above the H/N/L rows so the substantive corpus evidence remains visible above the lower viewport edge. No values or calculations changed.
    // v15.10.157: H/N/L corpus panel vertical-fit housekeeping. All corpus statistics and calculations are unchanged; presentation is compacted so the complete reference remains visible at the established Replay window size.
    // v15.10.156: self-contained Session-11 H/N/L corpus fallback; removes deployment-path dependency while retaining optional external corpus file loading.
    // v15.10.155: research-only H/N/L corpus bridge. When a session-specific 5,000-pair Method-A corpus summary is present, the bottom Previous Observations panel shows opening-three versus remainder-of-hand H/N/L manifestation across the paired corpus. Historical CURRENT/PREVIOUS evidence above remains unchanged. Descriptive context only; no prediction, controller, replay, settlement, wager, action, matching, or cardstream logic changed.
    // v15.10.135-UAT: surgical UAT correction from v15.10.134: All-Session observed/live AVG WAGER restored to mean initial wager per hand; exposure remains a separate metric. Session 11 therefore returns to £18.83. No cards, reconstruction, controller, settlement, P/L, exposure, layout, START-HAND terminology, or hole-card rendering changed.
    // v15.10.137-UAT: All-Session evidence-state repair only: Stat-Watching SOURCE_EXHAUSTED rows now show N/A (last £x) rather than promoting the last supported bankroll to FINAL BANKROLL, and the source-supported Stat-Watching exposure is displayed when present. No replay/controller/card/settlement logic changed.
    // v15.10.151: standalone Historical Replay final compact-layout correction. The previous-occurrence FULL HAND count box is raised into the same top band as the session/hand label, reclaiming vertical space for the aggregate evidence. The frozen-followed status line uses a compact presentation so the complete evidence label remains visible instead of clipping. Presentation-only: no replay progression, S-stage behaviour, evidence, card identity/order, reveal semantics, settlement, controller, wager, matching, or blackjack logic changed.
    // v15.10.154: final standalone Historical Replay GUI correction. When the CURRENT observation also shows the retained 5 CARDS BEFORE strip, the current-row height allowance is increased just enough to prevent the revealed dealer HOLE CARD from being clipped. Ace remains HIGH in the descriptive HIGH/NEUTRAL/LOW composition (10/J/Q/K/A); blackjack hand arithmetic remains unchanged. Presentation-only: no replay progression, matching semantics, card identity/order, reveal semantics, settlement, controller, wager, shuffle, or blackjack logic changed.
    // v15.10.153: standalone Historical Replay expert-analysis layout freeze. CURRENT and PREVIOUS use matched typography and card scale with clearer separation; the orientation heading and real-time count block are slightly reduced; the detailed previous occurrence no longer duplicates a full-hand count box. The aggregate evidence block now reports one rounded HIGH/NEUTRAL/LOW full-hand composition across all matched historical hands, one Average P/L per matched hand computed as the sum of each matched hand's observed bankroll change divided by the number of matched hands, plus W/L/P frequencies. Presentation/derived-summary only: no replay progression, S-stage behaviour, matching semantics, card identity/order, reveal semantics, settlement, controller, wager, or blackjack logic changed.
    // v15.10.152: standalone Historical Replay evidence-panel polish. The empty current-hand “5 CARDS BEFORE none recorded” line is suppressed when there is no prior-card evidence; the current dealer hole card is enlarged to the same compact evidence scale as the historical hole card while preserving face-down reveal semantics; the aggregate note “* Average full hand card count (rounded)” now sits left-aligned above ALL PREVIOUS OCCURRENCES, and the HIGH/NEUTRAL/LOW average values are enlarged for faster visual comparison. Presentation-only: no replay progression, S-stage behaviour, evidence, matching, card identity/order, reveal semantics, settlement, controller, wager, or blackjack logic changed.
    // v15.10.150: standalone Historical Replay bottom-summary fit correction. WIN/LOSS/PUSH and their rounded average full-hand HIGH/NEUTRAL/LOW composition are now rendered on the same compact row, so the entire ALL PREVIOUS OCCURRENCES section remains visible at the established replay window size. Zero-count outcomes show dashes rather than inventing an average. Presentation-only: no replay progression, S-stage behaviour, evidence, card identity/order, reveal semantics, settlement, controller, wager, matching, or blackjack logic changed.
    // v15.10.150: standalone Historical Replay vertical-fit correction. The expanding spacer between CURRENT and the single detailed PREVIOUS occurrence is replaced with a fixed compact gap, and the separator spacing before ALL PREVIOUS OCCURRENCES is reduced so the complete WIN/LOSS/PUSH aggregate and rounded HIGH/NEUTRAL/LOW averages remain visible at the established replay window size. Presentation-only: no replay progression, S-stage behaviour, evidence, card identity/order, reveal semantics, settlement, controller, wager, matching, or blackjack logic changed.
    // v15.10.148: standalone Historical Replay analytical-layout refinement. OPENING CARDS is removed from the comparison panel; the current dealer hole card is retained as the only opening-stream context and remains face-down until actually revealed. The current 5 CARDS BEFORE strip remains but its duplicate count summary is removed. The single detailed previous occurrence now shows a static FULL-HAND HIGH/NEUTRAL/LOW count and no previous 5-card strip. ALL PREVIOUS OCCURRENCES now adds rounded average full-hand HIGH/NEUTRAL/LOW composition by WIN/LOSS/PUSH outcome. Matching semantics remain decision-equivalent (10/J/Q/K share value 10; suits ignored) and were already present in the frozen replay matcher. No replay progression, S-stage behaviour, evidence, card identity/order, reveal semantics, settlement, controller, wager, or blackjack logic changed.
    private static final String APP_VERSION = "15.10.382-UAT";
    // v15.10.281-FREEZE: final cumulative presentation/role lock: compact Stat-Watching Stay Counterfactual split-screen heading; persistent normal-Replay controller slots explicitly fixed to Stat-Watching Casual + Ultimate AI. Fixed-Policy Casual remains Compare-only research evidence. No controller policy/evidence/bankroll/card semantics changed.
    // v15.10.280-UAT: Post-exit Stat-Watching presentation frozen for review: persistent prominent behavioural-exit bankroll benchmark, unclipped compact stay-counterfactual heading, and post-exit metrics label corrected to Stay Counterfactual. Persistent right-side controller slots remain Stat-Watching Casual + Ultimate AI; Fixed-Policy Casual remains a Compare research target. No evidence/card/bankroll/controller semantics changed.
    // v15.10.278-UAT: Stat-Watching split-screen now surfaces exact retained INFLUENCE observations/rationale on the applicable hand (e.g. Session 14 H8 exit), cross-checked against the displayed exit bankroll. Post-exit stay evidence never invents thoughts. Release-baseline self-registration repaired.
    // v15.10.277-UAT: Stat-Watching Compare now uses the established two-pane comparison window. H1-H8 use retained rich SW_TRACE; after the behavioural exit the right pane remains present as an explicitly bankroll-only STAY-AT-TABLE COUNTERFACTUAL through the retained stay path. Frozen continues graphically; no post-exit cards/actions/wagers are invented.
    // v15.10.275-UAT: Main Frozen Replay now carries a persistent, prominent Stat-Watching / Ultimate counterfactual strip directly above the cards. The separate Stat-Watching bankroll prototype is retired from the normal Compare workflow; Frozen remains the visible hand chronology while controller exit/stay evidence advances with it. No cards/actions are invented.
    // v15.10.274-UAT: Frozen replay now carries an in-screen controller continuation strip for Stat-Watching and Ultimate AI. Behavioural/canonical exits remain fixed while source-supported stay-at-table evidence continues; absent Ultimate stay evidence is explicitly N/A, never invented. Stat-Watching parsing uses the latest appended authoritative block.
    // v15.10.273-UAT: Stat-Watching Casual is now an explicit Replay target whenever retained evidence is replayable. Its Replay shows the behavioural chosen path and the source-supported stay-at-table counterfactual with the walk-away boundary visible; no post-exit cards/actions are invented. The former two-target selector cap no longer hides valid retained controllers.
    // v15.10.272-UAT: Release-baseline registration repair for the already-audited publication controller-state lock; protected fingerprints and deployed identity now advance with the intentional source change. No replay/card/controller semantics changed.
    // v15.10.271-UAT: Publication controller-state lock. S handoffs explicitly declare the current canonical Fixed-Policy Casual completion/source state and prohibit stale historical/reference source-state labels from overriding it. No replay, card, wager, settlement or controller policy changed.
    // v15.10.270-UAT: Publication Re-Audit integration. Embedded CLI V9.2.79 allows S, when no PENDING analysis exists, to explicitly re-audit the latest retained session from current canonical evidence. Existing accepted SessionN.png is never overwritten by S; operator confirmation is required and a fresh manifest/handoff is produced for review. No replay, card, wager, settlement or controller policy changed.
    // v15.10.269-UAT: Research-evidence closure only. Records the Session 14 historical-wager/canonical-chronology feasibility boundary: historical £30 overrides at H17/H18 leave £10, so the retained H19 £30 instruction is non-executable. Fail closed: no wager clipping/substitution and no manufactured final bankroll. No accepted v15.10.268 comparison presentation geometry or canonical-card provenance is changed.
    // v15.10.268-UAT: Release-baseline closure for authorised canonical-card badge restoration; integrity gate fingerprint re-established without weakening gate.
    // v15.10.267-UAT: Session 14 Ultimate canonical card badges restored from exact canonical-card matches only; no inferred/renumbered provenance.
    // v15.10.266-UAT: Session 14 Ultimate comparison presentation stabilization. The canonical chronology header/evidence/decision bands now reserve full-width fixed geometry so navigation cannot reflow the navy header between hands. Controller identity headers now have a fixed minimum height, preventing H25 recorded-rationale content from compressing or clipping the established Frozen/Ultimate thumbnails. Presentation only; chronology, cards, wagers, actions, bankrolls, settlement, rationale evidence and Live Play are unchanged.
    // v15.10.208: Comparison split-result parity. Reconstructed Frozen (and Fixed-Policy Casual where split) now show Split A/B child outcomes beside the retained round result, matching the established observed-Hybrid presentation. Derived from already-rendered branch/dealer totals only; no cards, actions, settlement, bankroll, controller, evidence or Live Play logic changed.
    // v15.10.207: Final presentation-only split-animation tidy. The standalone intermediate PLAYER SPLITS explanatory message now uses two centred lines instead of Swing ellipsis. No evidence, reconstruction, totals, settlement, bankroll, comparison or Live Play logic changed.
    // v15.10.206: Final Replay presentation tidy. Standalone split settlement now renders the overall hand verdict and display-settlement consistency verdict on two centred lines so neither can be truncated. v15.10.205 recovered-card rendering and comparison settlement presentation are retained unchanged. No evidence, totals, bankroll, settlement or Live Play capture logic changed.
    // v15.10.203: Historical Replay evidence-integrity repair. Recover an otherwise unrendered final split-child hit card when the detailed audit retains a child BUST total plus an unallocated captured PLAYER post-deal card; independently compare rendered settlement against the retained bankroll movement and flag contradictions without changing evidence or Live Play capture.
    // v15.10.202: Frozen-vs-Casual parity repair. Apply the already-accepted split-branch compact-fit and independent Split A/B action trails to CompareWindow as well as Frozen-vs-Hybrid. Black centre divider retained. Presentation only; no cards, controller, settlement, evidence, bankroll or replay logic changed.
    // v15.10.201: Juxtaposed Replay split clarity repair. Split A/B now show independent branch action trails derived from the already-recorded/reconstructed action sequence; genuine 5+ card branches retain local compact-fit rendering. Frozen/Hybrid and Frozen/Casual use a black centre divider. Presentation only; no cards, controller, settlement, evidence, bankroll or replay logic changed.
    // v15.10.200: Juxtaposed Replay extreme split-card presentation repair. Five-card split branches use a tighter local card gap/width so every child card remains on one visible row; normal cards and all replay/evidence logic are unchanged.
    // v15.10.199: Replay LIVE PLAY now delegates to the UAT-proven RUN_LIVE_GUI.bat release launcher; no replay/evidence logic changed.
    // v15.10.197-UAT: presentation-only controller identity completion and Frozen-vs-Casual footer-fit tidy.
    // Main Historical Replay now carries the established controller thumbnail; Frozen-vs-Casual uses the same compact legend treatment as Frozen-vs-Hybrid.
    // v15.10.126-UAT: Frozen-vs-Casual replay disagreement banner is globally controller-relative: CASUAL: <Frozen action> → <Casual action> (DIFFERS FROM FROZEN). This is presentation wording only and applies to every historical/current session using the shared Frozen-vs-Casual replay path; no replay evidence, action logic, cardstream, bankroll, controller policy, or comparison calculation changed.
    // v15.10.125-UAT: post-Session-11 live GUI lifecycle/input repair. Q now closes the graphical companion when the authoritative backend reports Program closed. Card-stream-review prompt transitions strip stale reconstructed-card text from QUICK INPUT while preserving a legitimately typed trailing Y/N token. Embedded CLI V9.2.35-UAT adds evidence-driven Session 7/Session 10 publication precedent and mandatory-module selection after current-session evidence is calculated. No blackjack play/controller policy changed.
    // v15.10.124-UAT: legacy Session 1 header "SESSION 20260903_004704 (CORRECTED)" is parsed again and pinned to publication Session 1. This restores Session 1 to All-Session Summary/running P/L without changing any hand evidence or comparison logic.
    // v15.10.123-UAT: All-Session Evidence Summary now opens with a vivid five-controller cumulative Running Profit/Loss bar. It uses current loaded evidence only and preserves source-completion boundaries; no blackjack decision/play policy changed.
    // v15.10.121-UAT: Session 11 publication-ready integration. Embeds CLI V9.2.32-UAT, which adds an S-stage plate-ready verified manifest and the signed-off Session 10 publication architecture: full running-P/L bar, protected bottom interpretation panels, Risk/Journey parity and Journey-ID-only header metadata. No blackjack decision/play policy or replay UI behaviour changed.
    // v15.10.120-UAT: Session 11 final-UAT handoff integration. Embeds CLI V9.2.31-UAT, which creates a one-file ChatGPT publication handoff ZIP automatically after S, reads the previous accepted plate from session_evidence\plates\SessionN.png, generates the updated graphical-canvas/visual-QC publication request, and preserves Journey ID plus supported Ultimate-AI final-bankroll header rules. No blackjack decision/play policy changed.
    // v15.10.119-UAT: final Ultimate-AI presentation polish only: wrapped override key, explicit Frozen source-exhaustion wording, and latest-session AI decision story. No controller/play logic changed.
    // v15.10.118-UAT: Ultimate AI Session Summary evidence integration: reads dedicated AI completion/audit evidence and adds source-bounded decision-point P/L where zero action overrides preserve the Frozen card-state path. No controller/play logic changed.
    // v15.10.117-UAT: Session Summary clarity-only header wording: P/L SAME DECISION POINT and P/L DIFFERENT DECISION POINT, wrapped over two lines; no evidence values or calculations changed.
    // v15.10.116-UAT: robust Session 10 Hybrid mindset/comment attachment from dedicated live-session evidence plus taller wrapped notes panel; no replay/controller/evidence calculations changed.
    // v15.10.115-UAT: removes nested per-session table scrollbars from Session Summary so every player row is visible at a glance; the outer summary window remains scrollable. No evidence, replay, Stats Corner, or controller logic changed.
    // v15.10.113-UAT: Stats Corner publication-number repair + section spacing only. Known curated session IDs are pinned to their accepted Session 1-10 publication numbers so Session 10 cannot be relabelled S9 when a retained ledger block is absent. Added visual separation before each new player section. No blackjack, replay, comparison, Stat-Watching or Ultimate-AI decision logic changed.
    // v15.10.113-UAT: presentation/evidence-link UAT only: fixed-policy Casual label, wrapped Stats tooltip/top margin, dedicated-report Hybrid comments, and verified Session10 #131 6C card identity in comparison replay.
    // v15.10.111-UAT: Session 10 evidence-facing UAT only. Ultimate AI Stats Corner honours AI_EXIT completion semantics and rank-known U/? replayability.
    // v15.10.110-UAT: Frozen-vs-Casual Replay now uses the same local SAME/DIFFERENT decision-point run logic and end-of-replay cumulative comparison page as Frozen-vs-Hybrid, bounded strictly to mutually source-supported hands. Existing hand-page evidence, cards, metrics and reconstruction logic remain unchanged.
    // v15.10.107-UAT: replay centre-navigation layout. HAND POSITION and CARD POSITION VS FROZEN now sit enlarged between narrower green decision/profit boxes; Previous/Next remain on the row below. Presentation only; evidence and decision logic unchanged.
    // v15.10.106-UAT: replay navigation emphasis only. HAND POSITION and CARD POSITION VS FROZEN typography are enlarged inside the existing navigation row without increasing row height or changing any evidence, cards, metrics, notes, calculations, Stats Corner or decision logic.
    // v15.10.105-UAT: compact story-header / safe lower-stack fit. Story-title bands were tightened while the metric value area and safe green summary height were preserved.
    // v15.10.101-UAT: Replay final vertical micro-fit. The same-player decision banner now sits immediately above the hand/card-position navigation, with reduced vertical padding in that lower control stack so edge-case RESULT text remains fully visible without shrinking card panes. No evidence, card size, split capacity, calculations, replay logic or live decision behaviour changed.
    // v15.10.98-UAT: Stats Corner final micro-fit. The running-profit support caption now wraps onto two lines ("Across N playable" / "session(s)") so it cannot compete horizontally with the chart. No chart dimensions, evidence values, calculations, replay logic or live decision behaviour changed.
    // v15.10.96-UAT: Stats Corner final horizontal-fit refinement. The Stat-Watching running-profit summary column is narrower and uses slightly smaller descriptive typography, while headline monetary values remain prominent. Recovered width is returned directly to the plot so the final session/bar labels remain inside the visible chart. No chart data, evidence calculations, replay logic or live decision behaviour changed.
    // v15.10.95-UAT: Stats Corner narrow-viewport fit repair. Long explanatory labels use a deliberately narrower wrap width and the Stat-Watching chart reserves a larger right plot gutter so the final session/bar labels remain wholly inside the visible panel. No evidence calculations, chart data, replay logic or live decision behaviour changed.
    // v15.10.94-UAT: Stats Corner right-edge safety pass. Explanatory text wraps earlier with a reserved right gutter, and Stat-Watching chart footer captions are centred within the full visible panel so they cannot run beyond the right edge. No evidence calculations, chart data, replay logic or live decision behaviour changed.
    // v15.10.93-UAT: Decision-point streak values are visually distinguished with bold dark-green value text only; layout height, evidence logic, cards, notes, metrics and live/replay behaviour are unchanged.
    // v15.10.92-UAT: Hybrid Player Notes title-to-observation spacing tightened only, recovering final edge-case vertical pixels without changing cards, replay evidence, metrics, decisions, or split/long-hand capacity.
    // v15.10.91-UAT: compact Action/Result formatting in both Replay comparison modes so the result line stays visible without scrolling; card dimensions, split/long-hand capacity and all evidence calculations remain unchanged.
    // v15.10.90-UAT: final replay vertical-fit pass — preserves full card size and split/long-hand scrolling, while compacting only the evidence bands below the cards so Actions/Result, navigation and decision-warning remain visible without routine scrolling.
    // v15.10.87-UAT: moves journey story metrics into a segregated lower strip, enlarges bankroll/value typography, colour-codes peak/trough/W-L-P, and presents local pointer drift as a black/white badge.
    // v15.10.86-UAT: adds compact hand-by-hand journey story metrics to Replay (peak, trough, cumulative exposure, max drawdown, W/L/P, average bet).
    // v15.10.85-UAT: post-boundary yellow allocation highlighting restored; causal boundary retained separately.
    // v15.10.45 embeds CLI V9.2.18-UAT unchanged. Stats Corner story presentation only: per-influence outcome markers (✓/✗/—) clarify realised consequences, and a below-table-minimum Session 4 ending is presented as a forced bankroll exit rather than a voluntary walk. No Stat-Watching behavioural, evidence, replay, Frozen or Casual logic changed.
    private static final Color TABLE = new Color(16, 92, 72);
    private static final Color TABLE_DARK = new Color(9, 63, 51);
    private static final Color NAVY = new Color(10, 41, 75);
    private static final Color GOLD = new Color(220, 183, 74);
    private static final Color PANEL = new Color(245, 248, 250);

    private final JTextArea sessionLabel = new JTextArea("No session loaded");
    private final JLabel handLabel = new JLabel("HAND -- / --");
    private final JLabel bankrollLabel = new JLabel("Bankroll --");
    private final JLabel wagerLabel = new JLabel("Wager --");
    private final JLabel actionLabel = new JLabel(" ", SwingConstants.CENTER);
    private final JLabel resultLabel = new JLabel(" ", SwingConstants.CENTER);
    private final JLabel playerOutcomeLabel = new JLabel(" ", SwingConstants.CENTER);
    private final JLabel dealerTotalLabel = new JLabel("TOTAL --", SwingConstants.CENTER);
    private final JLabel playerTotalLabel = new JLabel("TOTAL --", SwingConstants.CENTER);
    private final ChipDisplayPanel chipDisplay = new ChipDisplayPanel();
    private final ShoePanel shoePanel = new ShoePanel();
    private final PreviousPanel previousPanel = new PreviousPanel();
    private final JPanel dealerCards = new JPanel(new FlowLayout(FlowLayout.CENTER, 12, 0));
    private final JPanel playerCards = new JPanel(new FlowLayout(FlowLayout.CENTER, 12, 0));
    private final JPanel splitCards = new JPanel(new GridLayout(1,2,10,0));
    private final JButton playButton = new JButton("▶ Play");
    private final JButton pauseButton = new JButton("⏸");
    private final JButton previousHandButton = new JButton("◀ Prev");
    private final JButton nextButton = new JButton("Next ▶");
    private final JButton compareButton = new JButton("COMPARE");
    private final JComboBox<String> compareReferenceBox = new JComboBox<>(new String[]{"Frozen"});
    private final JComboBox<String> compareTargetBox = new JComboBox<>(new String[]{"Fixed-Policy Casual", "Hybrid"});
    private final JButton openButton = new JButton("OPEN output.txt");
    private final JButton liveButton = new JButton("LIVE PLAY");
    private final JButton removeLastSessionButton = new JButton("REMOVE LAST SESSION");
    private final JComboBox<String> sessionBox = new JComboBox<>();
    private final JSlider speedSlider = new JSlider(1, 10, 4);
    private final JLabel speedValueLabel = new JLabel("1.55 s / step");
    private final RecentExperiencePanel journeyStrip = new RecentExperiencePanel();
    private final ControllerCounterfactualStrip controllerCounterfactualStrip = new ControllerCounterfactualStrip();

    private final java.util.List<Session> sessions = new ArrayList<>();
    // v15.10.319-UAT: selector catalogue may include publication-verified standalone preambles.
    // `sessions` remains formal-session-only so Stats Corner / all-session aggregates keep their established statistical boundary.
    private final java.util.List<Session> replayCatalog = new ArrayList<>();
    private Session currentSession;
    private int handIndex = -1;
    private int stage = 0;
    private int playerRevealIndex = 0;
    private int dealerRevealIndex = 0;
    private int splitARevealIndex = 0;
    private int splitBRevealIndex = 0;
    private int openingStreamRevealCount = 0;
    private javax.swing.Timer timer;
    private String loadedOutputText = "";
    private final boolean suppressOpeningMemorySplash;

    public BlackjackSessionReplay() {
        this(false);
    }

    private BlackjackSessionReplay(boolean suppressOpeningMemorySplash) {
        super("Deterministic Blackjack — Historical Session Replay • v" + APP_VERSION);
        this.suppressOpeningMemorySplash = suppressOpeningMemorySplash;
        // Release lifecycle hardening: closing the standalone Replay window must
        // terminate the JVM so no invisible Java process retains the GUI folder/JAR.
        setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
        addWindowListener(new WindowAdapter() {
            @Override public void windowClosing(WindowEvent e) {
                if (timer != null) timer.stop();
                dispose();
                System.exit(0);
            }
        });
        setMinimumSize(new Dimension(1220, 680));
        setSize(1540, 860);
        setLocationRelativeTo(null);
        setLayout(new BorderLayout());

        // Keep all interactive controls permanently visible at the top.  The
        // first proof-of-concept placed them at BorderLayout.SOUTH; on some
        // Windows DPI/display configurations the table's preferred height
        // could push that strip below the usable window area.
        JPanel north = new JPanel(new BorderLayout());
        north.add(buildTop(), BorderLayout.NORTH);
        north.add(buildControls(), BorderLayout.SOUTH);
        add(north, BorderLayout.NORTH);
        JPanel body=new JPanel(new BorderLayout(10,0)); body.setBackground(TABLE_DARK); body.setBorder(new EmptyBorder(0,10,10,10));
        shoePanel.setPreferredSize(new Dimension(310,650)); previousPanel.setPreferredSize(new Dimension(415,650));
        body.add(shoePanel,BorderLayout.WEST); body.add(buildTable(),BorderLayout.CENTER); body.add(previousPanel,BorderLayout.EAST); add(body,BorderLayout.CENTER);

        timer = new javax.swing.Timer(delayFromSpeed(speedSlider.getValue()), e -> tick());
        timer.setInitialDelay(delayFromSpeed(speedSlider.getValue()));
        speedSlider.addChangeListener(e -> {
            int d = delayFromSpeed(speedSlider.getValue());
            timer.setDelay(d);
            timer.setInitialDelay(d);
            speedValueLabel.setText(String.format("%.2f s / step", d / 1000.0));
        });
        playButton.addActionListener(e -> { if (currentSession != null) { timer.start(); pauseButton.setText("⏸"); if(handIndex < 0) nextHand(); }});
        pauseButton.addActionListener(e -> { if(timer.isRunning()){timer.stop();pauseButton.setText("▶");}else if(currentSession!=null){timer.start();pauseButton.setText("⏸");if(handIndex<0)nextHand();} });
        previousHandButton.addActionListener(e -> { timer.stop(); pauseButton.setText("▶"); previousHand(); });
        nextButton.addActionListener(e -> { timer.stop(); pauseButton.setText("▶"); nextHand(); });
        compareButton.addActionListener(e -> { timer.stop(); openSelectedComparison(); });
        compareTargetBox.addActionListener(e -> updateCompareAvailability());
        openButton.addActionListener(e -> chooseFile());
        liveButton.addActionListener(e -> launchLiveViaReleaseLauncher());
        removeLastSessionButton.addActionListener(e -> removeLastFormalSession());
        // v15.10.347-UAT: admit the retained Ultimate AI V2 controlled stress session to the Engineering UAT purpose page and compact/wrap the centre purpose copy so it remains fully visible. Presentation/catalogue only; no replay, evidence, settlement, controller or statistics semantics changed.
    // v15.10.344-UAT: engineering/constructed UAT evidence is visually distinct in Replay.
        sessionBox.setRenderer(new DefaultListCellRenderer() {
            @Override public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
                JLabel c=(JLabel)super.getListCellRendererComponent(list,value,index,isSelected,cellHasFocus);
                int ri=index>=0?index:sessionBox.getSelectedIndex();
                Session rs=(ri>=0&&ri<replayCatalog.size())?replayCatalog.get(ri):null;
                if(isEngineeringValidationSession(rs)) {
                    c.setForeground(isSelected?new Color(92,55,0):new Color(178,105,0));
                    if(isSelected)c.setBackground(new Color(255,232,190));
                    c.setFont(c.getFont().deriveFont(Font.BOLD));
                }
                return c;
            }
        });
        sessionBox.addActionListener(e -> {
            int i = sessionBox.getSelectedIndex();
            if (i >= 0 && i < replayCatalog.size()) selectSession(replayCatalog.get(i));
        });

        // Shared repository default: GUI and CLI are sibling folders; CLI owns authoritative research data.
        Path p = dataDirectory().resolve("output.txt");
        if (Files.exists(p)) {
            loadFile(p);
        } else {
            sessionLabel.setText("output.txt not found — use OPEN output.txt");
        }
    }

    // v15.10.199: Replay LIVE PLAY must use the same release launcher as the
    // directly UAT-proven RUN_LIVE_GUI.bat route.  The embedded constructor can
    // inherit Replay's JAR-only classpath and therefore cannot see the sibling CLI
    // backend class.  Launching the release BAT preserves its compile/classpath/
    // cleanup lifecycle and keeps Replay and Live as independent windows.
    private void launchLiveViaReleaseLauncher() {
        // v15.10.302: Live is another window in the Replay JVM, not another GUI JVM.
        // The Live controller still starts its authoritative CLI backend as a child
        // process, but the visible Swing applications now share one Java process.
        for (Window w : Window.getWindows()) {
            if (w instanceof BlackjackLiveGuiUAT && w.isDisplayable()) {
                w.toFront();
                w.requestFocus();
                JOptionPane.showMessageDialog(this,
                        "The Live Graphical Companion is already running.",
                        "Live Play already open", JOptionPane.INFORMATION_MESSAGE);
                return;
            }
        }
        // v15.10.304: prepare the source-distributed authoritative CLI before
        // constructing Live.  BlackjackLiveGuiOld starts its backend from its
        // constructor, so compilation must happen first (not inside startBackend).
        liveButton.setEnabled(false);
        new SwingWorker<Integer,Void>() {
            private String failure = null;
            @Override protected Integer doInBackground() {
                try {
                    Path cliDir = dataDirectory();
                    Path src = cliDir.resolve("LiveBlackjackCompanion_PersonalOrFrozen_OptionalHistory_Ledger.java");
                    if (!Files.isRegularFile(src)) { failure = "Authoritative CLI backend source was not found:\n" + src; return 2; }
                    String exe = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win") ? "javac.exe" : "javac";
                    Path javac = Paths.get(System.getProperty("java.home"), "bin", exe);
                    ProcessBuilder pb = Files.isRegularFile(javac)
                            ? new ProcessBuilder(javac.toString(), "-encoding", "UTF-8", src.getFileName().toString())
                            : new ProcessBuilder("javac", "-encoding", "UTF-8", src.getFileName().toString());
                    pb.directory(cliDir.toFile()); pb.redirectErrorStream(true);
                    Process proc = pb.start();
                    try (BufferedReader br = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
                        StringBuilder out = new StringBuilder(); String line;
                        while ((line = br.readLine()) != null) out.append(line).append('\n');
                        int rc = proc.waitFor();
                        if (rc != 0) failure = "Authoritative CLI compilation failed (exit " + rc + ").\n" + out;
                        return rc;
                    }
                } catch (Exception ex) { failure = ex.toString(); return 3; }
            }
            @Override protected void done() {
                liveButton.setEnabled(true);
                try {
                    if (get() != 0) {
                        JOptionPane.showMessageDialog(BlackjackSessionReplay.this, failure,
                                "Live Play could not start", JOptionPane.ERROR_MESSAGE);
                        return;
                    }
                    new BlackjackLiveGuiUAT().setVisible(true);
                } catch (Exception ex) {
                    JOptionPane.showMessageDialog(BlackjackSessionReplay.this, ex.toString(),
                            "Live Play could not start", JOptionPane.ERROR_MESSAGE);
                }
            }
        }.execute();
    }

    private Path applicationDirectory() {
        try {
            Path location = Paths.get(BlackjackSessionReplay.class.getProtectionDomain()
                    .getCodeSource().getLocation().toURI()).toAbsolutePath();
            return Files.isDirectory(location) ? location : location.getParent();
        } catch (Exception ex) {
            // Safe fallback for IDE/source launches. RUN_REPLAY.bat also starts in the app folder.
            return Paths.get("").toAbsolutePath();
        }
    }

    private Path dataDirectory() {
        Path app = applicationDirectory();
        Path parent = app.getParent();
        return parent != null ? parent.resolve("CLI") : app;
    }

    private JComponent buildTop() {
        JPanel top = new JPanel();
        top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
        top.setBackground(NAVY);
        top.setBorder(new EmptyBorder(7,22,5,22));

        JLabel title = new JLabel("DETERMINISTIC BLACKJACK • GRAPHICAL SESSION REPLAY • v" + APP_VERSION);
        title.setForeground(Color.WHITE);
        title.setFont(new Font("SansSerif", Font.BOLD, 24));

        JButton statsButton = new JButton("STATS CORNER");
        statsButton.setFont(new Font("SansSerif", Font.BOLD, 11));
        statsButton.setForeground(NAVY);
        statsButton.setBackground(new Color(245, 248, 251));
        statsButton.setFocusPainted(false);
        statsButton.setPreferredSize(new Dimension(132, 30));
        statsButton.setToolTipText("Open Fascinating Stats Corner without restarting the replay application");
        statsButton.addActionListener(e -> showStatsCornerDialog(this));

        JButton summaryButton = new JButton("SESSION SUMMARY");
        summaryButton.setFont(new Font("SansSerif", Font.BOLD, 11));
        summaryButton.setForeground(NAVY);
        summaryButton.setBackground(new Color(245, 248, 251));
        summaryButton.setFocusPainted(false);
        summaryButton.setPreferredSize(new Dimension(142, 30));
        summaryButton.setToolTipText("Open all-session source-supported player/controller summary");
        summaryButton.addActionListener(e -> showSessionSummaryDialog(this));

        JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 7, 0));
        actions.setOpaque(false); actions.add(summaryButton); actions.add(statsButton);

        JPanel titleRow = new JPanel(new BorderLayout(14, 0));
        titleRow.setOpaque(false);
        titleRow.setAlignmentX(Component.LEFT_ALIGNMENT);
        titleRow.setMaximumSize(new Dimension(Integer.MAX_VALUE, 34));
        titleRow.add(title, BorderLayout.WEST);
        titleRow.add(actions, BorderLayout.EAST);

        JLabel sub = new JLabel("Historical playback only • cards and outcomes are read from output.txt • no cards are invented");
        sub.setForeground(new Color(205,220,235));
        sub.setFont(new Font("SansSerif", Font.PLAIN, 13));
        sub.setAlignmentX(Component.LEFT_ALIGNMENT);

        // v13.3: session description is experimental provenance, so give it the
        // entire header width and allow long names to wrap instead of truncating.
        sessionLabel.setForeground(Color.WHITE);
        sessionLabel.setFont(new Font("SansSerif", Font.BOLD, 15));
        sessionLabel.setOpaque(false);
        sessionLabel.setEditable(false);
        sessionLabel.setFocusable(false);
        sessionLabel.setLineWrap(true);
        sessionLabel.setWrapStyleWord(true);
        sessionLabel.setBorder(null);
        sessionLabel.setMargin(new Insets(0,0,0,0));
        sessionLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
        sessionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 30));
        sessionLabel.setPreferredSize(new Dimension(1200, 25));

        top.add(titleRow);
        top.add(Box.createVerticalStrut(2));
        top.add(sub);
        top.add(Box.createVerticalStrut(2));
        top.add(sessionLabel);
        return top;
    }

    private JComponent buildTable() {
        JPanel table = new JPanel();
        table.setBackground(TABLE);
        table.setLayout(new BoxLayout(table, BoxLayout.Y_AXIS));
        table.setBorder(new EmptyBorder(4,30,8,30));

        // Replay UAT v15.10.176: keep both table-summary lines permanently visible.
        // A single FlowLayout could wrap Wager/committed underneath the experience strip at
        // narrower widths.  Use two explicit rows instead: hand+bankroll, then wager/exposure.
        JPanel meta = new JPanel(); meta.setOpaque(false); meta.setLayout(new BoxLayout(meta,BoxLayout.Y_AXIS));
        JPanel metaTop = new JPanel(new FlowLayout(FlowLayout.CENTER,45,0)); metaTop.setOpaque(false);
        for (JLabel l : new JLabel[]{handLabel, bankrollLabel}) {
            l.setForeground(Color.WHITE); l.setFont(new Font("SansSerif", Font.BOLD, 18)); metaTop.add(l);
        }
        JPanel metaBottom = new JPanel(new FlowLayout(FlowLayout.CENTER,0,0)); metaBottom.setOpaque(false);
        wagerLabel.setForeground(Color.WHITE); wagerLabel.setFont(new Font("SansSerif", Font.BOLD, 18)); metaBottom.add(wagerLabel);
        meta.add(metaTop); meta.add(metaBottom);
        meta.setPreferredSize(new Dimension(1000,44)); meta.setMaximumSize(new Dimension(Integer.MAX_VALUE,44));
        table.add(meta);

        // v15.10.288-UAT: keep exactly ONE Recent Player Experience strip in the table.
        // The lower post-LOSS area remains intentionally empty; do not duplicate journeyStrip there.
        // v15.10.283: controller exit/stay summaries belong only in their dedicated
        // Compare views.  Do not inject them into normal Historical Replay: the extra
        // strip displaced the established decision/action area and right-side evidence.
        controllerCounterfactualStrip.setVisible(false);

        journeyStrip.setAlignmentX(Component.CENTER_ALIGNMENT);
        journeyStrip.setMinimumSize(new Dimension(0,70));
        journeyStrip.setMaximumSize(new Dimension(Integer.MAX_VALUE, 70));
        journeyStrip.setPreferredSize(new Dimension(1000,70));
        table.add(journeyStrip);
        table.add(Box.createVerticalStrut(0));

        // v15.10.192: chips are no longer a BoxLayout row. They are painted as a
        // temporary pre-deal overlay, so they cannot push dealer/player cards downward.
        // The overlay is removed at the first dealing tick.

        table.add(zoneHeader("DEALER", dealerTotalLabel));
        dealerCards.setOpaque(false); dealerCards.setPreferredSize(new Dimension(1000,132)); dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132)); table.add(dealerCards);

        actionLabel.setForeground(new Color(255,245,190)); actionLabel.setFont(new Font("SansSerif", Font.BOLD, 19));
        actionLabel.setPreferredSize(new Dimension(1000,28));
        actionLabel.setMinimumSize(new Dimension(0,28));
        actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
        actionLabel.setAlignmentX(Component.CENTER_ALIGNMENT); table.add(actionLabel);

        table.add(zoneHeader("PLAYER", playerTotalLabel));
        playerCards.setOpaque(false); playerCards.setPreferredSize(new Dimension(1000,132)); playerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132)); table.add(playerCards);
        playerOutcomeLabel.setForeground(new Color(255, 246, 196)); playerOutcomeLabel.setFont(new Font("SansSerif", Font.BOLD, 20));
        // v15.10.189: only the normal-hand contextual verdict is pulled closer to the cards.
        // The smaller lower inset also compensates for the extra white-strip height without
        // moving dealer/player/card/footer containers. Split geometry remains untouched.
        playerOutcomeLabel.setBorder(new EmptyBorder(0, 0, 0, 0));
        playerOutcomeLabel.setPreferredSize(new Dimension(1000,28));
        playerOutcomeLabel.setMinimumSize(new Dimension(0,28));
        playerOutcomeLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
        playerOutcomeLabel.setAlignmentX(Component.CENTER_ALIGNMENT); table.add(playerOutcomeLabel);
        splitCards.setOpaque(false); splitCards.setPreferredSize(new Dimension(900,96)); splitCards.setMaximumSize(new Dimension(900,96)); splitCards.setVisible(false); table.add(splitCards);

        resultLabel.setForeground(Color.WHITE); resultLabel.setFont(new Font("SansSerif", Font.BOLD, 26));
        // v15.10.355-UAT: anchor the complete bottom result/status block to the TOP of its
        // reserved overlay lane. On 1080p the previous vertical centring could leave the
        // compact settlement/status row below the visible client area. Presentation only.
        resultLabel.setVerticalAlignment(SwingConstants.TOP);
        // v15.10.180: reserve a permanent bottom verdict lane for normal-hand settlement.
        // This prevents WIN/LOSS/PUSH from being pushed below the visible felt after the
        // two-line summary was frozen. Split playback keeps its accepted v15.10.180 layout.
        resultLabel.setPreferredSize(new Dimension(1000,104));
        resultLabel.setMinimumSize(new Dimension(0,104));
        resultLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,104));
        // v15.10.192: the ordinary settled-hand verdict is a felt overlay rather than a
        // BoxLayout row. This guarantees WIN/LOSS/PUSH remains visible at the bottom of
        // the table at 1080p without pushing or shrinking any dealer/player card lane.
        resultLabel.setAlignmentX(Component.CENTER_ALIGNMENT);

        // v15.10.192: overlay the pre-deal bankroll/on-table chips on the felt without
        // consuming vertical layout space. This preserves the established animation
        // sequence while returning the full former chip-row height to the cards/messages.
        JLayeredPane layered = new JLayeredPane() {
            @Override public void doLayout() {
                table.setBounds(0, 0, getWidth(), getHeight());
                chipDisplay.setBounds(0, 0, getWidth(), getHeight());
                // v15.10.360-UAT: lift and deepen the bottom verdict/status overlay so its
                // compact settlement-consistency line remains above the Windows work-area edge.
                // Overlay-only: no dealer/player/card lane geometry is consumed.
                resultLabel.setBounds(0, Math.max(0, getHeight()-116), getWidth(), 104);
            }
            @Override public Dimension getPreferredSize() { return table.getPreferredSize(); }
            @Override public Dimension getMinimumSize() { return table.getMinimumSize(); }
        };
        layered.setOpaque(false);
        layered.add(table, JLayeredPane.DEFAULT_LAYER);
        layered.add(chipDisplay, JLayeredPane.PALETTE_LAYER);
        layered.add(resultLabel, JLayeredPane.MODAL_LAYER);
        chipDisplay.setOpaque(false);
        return layered;
    }

    private JLabel zoneTitle(String s) {
        JLabel l = new JLabel(s, SwingConstants.CENTER); l.setForeground(Color.WHITE);
        l.setFont(new Font("SansSerif", Font.BOLD, 16)); l.setAlignmentX(Component.CENTER_ALIGNMENT); return l;
    }

    private JComponent zoneHeader(String name, JLabel total) {
        JPanel p = new JPanel(new FlowLayout(FlowLayout.CENTER, 18, 0));
        p.setOpaque(false);
        JLabel n = zoneTitle(name);
        total.setForeground(new Color(255, 235, 160));
        total.setFont(new Font("SansSerif", Font.BOLD, 16));
        p.add(n); p.add(total);
        p.setPreferredSize(new Dimension(1000,26));
        p.setMinimumSize(new Dimension(0,26));
        p.setMaximumSize(new Dimension(Integer.MAX_VALUE,26));
        p.setAlignmentX(Component.CENTER_ALIGNMENT);
        return p;
    }

    private JComponent buildSameStateOrientationHeader() {
        JPanel headRow=new JPanel(new BorderLayout(8,0)); headRow.setOpaque(false); headRow.setPreferredSize(new Dimension(390,30));
        JPanel key=new JPanel(); key.setOpaque(false); key.setLayout(new BoxLayout(key,BoxLayout.Y_AXIS));
        JLabel kh=new JLabel("↑ HIGH (10 – A)"), kn=new JLabel("— NEUTRAL (7 – 9)"), kl=new JLabel("↓ LOW (2 – 6)");
        for(JLabel k:new JLabel[]{kh,kn,kl}) { k.setFont(new Font("SansSerif",Font.BOLD,8)); key.add(k); } kh.setForeground(new Color(25,125,75)); kn.setForeground(new Color(45,85,165)); kl.setForeground(new Color(190,55,50));
        JPanel heading=new JPanel(); heading.setOpaque(false); heading.setLayout(new BoxLayout(heading,BoxLayout.Y_AXIS));
        JLabel h1=new JLabel("SAME STARTING STATE",SwingConstants.CENTER); h1.setFont(new Font("SansSerif",Font.BOLD,14)); h1.setForeground(NAVY); h1.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel h2=new JLabel("CURRENT + PREVIOUS OBSERVATIONS",SwingConstants.CENTER); h2.setFont(new Font("SansSerif",Font.PLAIN,10)); h2.setForeground(new Color(70,82,98)); h2.setAlignmentX(Component.CENTER_ALIGNMENT);
        heading.add(h1); heading.add(h2); headRow.add(key,BorderLayout.WEST); headRow.add(heading,BorderLayout.CENTER); return headRow;
    }

    private JComponent buildControls() {
        // v15.10.73-UAT: two-row replay control bar.
        // v15.10.83-UAT retains this layout; Hybrid Compare now enforces an explicit dealer/source-rule comparability boundary and restores Casual fallback on non-Hybrid sessions.
        // Row 1 keeps normal replay/navigation and playback-speed controls.
        // Row 2 is reserved for journey comparison so REF / VS / COMPARE can
        // never be squeezed off-screen by the normal replay toolbar.
        // No replay, comparison, Stats Corner, or evidence logic is changed.
        JPanel p = new JPanel(new BorderLayout(0,6));
        p.setBackground(PANEL);
        p.setBorder(new CompoundBorder(
                new MatteBorder(1,0,1,0,new Color(210,216,220)),
                new EmptyBorder(8,16,8,16)));

        JPanel top = new JPanel(new BorderLayout(12,0));
        top.setOpaque(false);

        JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT,6,0));
        left.setOpaque(false);
        left.add(liveButton);
        removeLastSessionButton.setForeground(new Color(150, 25, 25));
        removeLastSessionButton.setToolTipText("Safely remove only the chronologically latest formal live session; creates a recovery backup first");
        left.add(removeLastSessionButton);
        left.add(new JLabel("Session:"));
        // v15.10.326-UAT: use the space released by relocating OPEN output.txt so
        // full formal-session / standalone-preamble identities remain readable.
        sessionBox.setPreferredSize(new Dimension(360,30));
        sessionBox.setMaximumSize(new Dimension(360,30));
        left.add(sessionBox);

        JPanel transport = new JPanel(new GridLayout(1,4,6,0));
        transport.setOpaque(false);
        // v15.10.249-UAT: compact transport labels avoid Windows ellipsis without widening the toolbar.
        // BorderLayout.CENTER may otherwise compress the GridLayout cells to icon/ellipsis fragments.
        transport.setPreferredSize(new Dimension(400,32));
        transport.setMinimumSize(new Dimension(400,32));
        previousHandButton.setPreferredSize(new Dimension(96,30));
        nextButton.setPreferredSize(new Dimension(96,30));
        playButton.setPreferredSize(new Dimension(96,30));
        pauseButton.setPreferredSize(new Dimension(88,30));
        pauseButton.setFont(pauseButton.getFont().deriveFont(Font.BOLD, 18f));
        pauseButton.setToolTipText("Pause / resume playback");
        transport.add(previousHandButton);
        transport.add(nextButton);
        transport.add(playButton);
        transport.add(pauseButton);

        JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT,6,0));
        right.setOpaque(false);
        right.add(new JLabel("Playback speed:"));
        JLabel slow = new JLabel("SLOW");
        slow.setFont(new Font("SansSerif", Font.BOLD, 11));
        right.add(slow);
        speedSlider.setOpaque(false);
        speedSlider.setPreferredSize(new Dimension(82,30));
        speedSlider.setMajorTickSpacing(1);
        speedSlider.setSnapToTicks(true);
        speedSlider.setToolTipText("Move left for slower playback, right for faster playback");
        right.add(speedSlider);
        JLabel fast = new JLabel("FAST");
        fast.setFont(new Font("SansSerif", Font.BOLD, 11));
        right.add(fast);
        speedValueLabel.setPreferredSize(new Dimension(68,24));
        right.add(speedValueLabel);

        top.add(left, BorderLayout.WEST);
        top.add(transport, BorderLayout.CENTER);
        top.add(right, BorderLayout.EAST);

        JPanel compareRow = new JPanel(new FlowLayout(FlowLayout.CENTER,7,0));
        compareRow.setOpaque(false);
        compareRow.setBorder(new EmptyBorder(2,0,0,0));

        JLabel compareTitle = new JLabel("COMPARE JOURNEYS");
        compareTitle.setFont(new Font("SansSerif", Font.BOLD, 12));
        compareTitle.setForeground(new Color(18,58,98));
        compareRow.add(compareTitle);
        compareRow.add(Box.createHorizontalStrut(10));

        JLabel refLabel = new JLabel("REF:");
        refLabel.setFont(new Font("SansSerif",Font.BOLD,11));
        compareRow.add(refLabel);
        compareReferenceBox.setEnabled(false);
        compareReferenceBox.setPreferredSize(new Dimension(86,30));
        compareRow.add(compareReferenceBox);

        JLabel vsLabel = new JLabel("VS:");
        vsLabel.setFont(new Font("SansSerif",Font.BOLD,11));
        compareRow.add(vsLabel);
        compareTargetBox.setPreferredSize(new Dimension(300,30));
        compareRow.add(compareTargetBox);

        compareButton.setFont(new Font("SansSerif",Font.BOLD,11));
        compareButton.setPreferredSize(new Dimension(100,30));
        compareRow.add(compareButton);

        // v15.10.334-UAT: preserve the accepted compact toolbar; trim VS selector by 20 px so OPEN output.txt sits slightly left.
        // v15.10.333-UAT: compact the lower toolbar back to one row.
        // Compare controls remain fully visible, OPEN output.txt remains available, and
        // the same-state orientation key stays at the right without consuming a third row.
        JPanel lower=new JPanel(new BorderLayout(10,0));
        lower.setOpaque(false);

        JPanel lowerLeft=new JPanel(new FlowLayout(FlowLayout.LEFT,7,0));
        lowerLeft.setOpaque(false);
        lowerLeft.add(compareTitle);
        lowerLeft.add(Box.createHorizontalStrut(4));
        lowerLeft.add(refLabel);
        lowerLeft.add(compareReferenceBox);
        lowerLeft.add(vsLabel);
        lowerLeft.add(compareTargetBox);
        lowerLeft.add(compareButton);
        lowerLeft.add(Box.createHorizontalStrut(8));
        lowerLeft.add(openButton);

        lower.add(lowerLeft,BorderLayout.WEST);
        lower.add(buildSameStateOrientationHeader(),BorderLayout.EAST);

        p.add(top, BorderLayout.NORTH);
        p.add(lower, BorderLayout.SOUTH);
        return p;
    }


    private static int delayFromSpeed(int speed) {
        // Human-friendly speed control: 1 = slowest, 10 = fastest.
        // Delay is intentionally non-linear enough to make the slow end visibly slower.
        int[] delays = {0, 3200, 2600, 2050, 1550, 1200, 900, 700, 520, 380, 260};
        int s = Math.max(1, Math.min(10, speed));
        return delays[s];
    }


    // v15.10.226: destructive-maintenance control requested for UAT cleanup.
    // Only the chronologically latest formal live session is eligible. A timestamped
    // ZIP recovery snapshot is created before any mutation. Previous-session preamble
    // source files are deliberately preserved.
    private void removeLastFormalSession() {
        timer.stop();
        Path cli = dataDirectory();
        Path evidence = cli.resolve("session_evidence");
        try {
            LatestFormalSession latest = findLatestFormalLiveSession(evidence);
            if (latest == null) {
                JOptionPane.showMessageDialog(this,
                        "No removable formal live session evidence was found.",
                        "Remove Last Session", JOptionPane.INFORMATION_MESSAGE);
                return;
            }
            String previousLabel = latest.publicationNumber > 1
                    ? "Session " + (latest.publicationNumber - 1) : "the previous retained session";

            // v15.10.250: publication-state safeguard.  Removal must know whether an
            // accepted plate exists for the session being rolled back; CANCEL is fail-closed.
            Object[] plateOptions = {"YES — plate completed", "NO — plate not completed", "CANCEL"};
            int plateChoice = JOptionPane.showOptionDialog(this,
                    "Before removing Session " + latest.publicationNumber + ":\n\n" +
                    "Has its publication plate been completed and accepted?\n\n" +
                    "This answer is recorded in the recovery snapshot. If YES, the canonical\n" +
                    "Session" + latest.publicationNumber + ".png plate is removed with the session.",
                    "Publication plate status — Session " + latest.publicationNumber,
                    JOptionPane.DEFAULT_OPTION, JOptionPane.WARNING_MESSAGE, null,
                    plateOptions, plateOptions[2]);
            if (plateChoice < 0 || plateChoice == 2) return;
            boolean publicationPlateCompleted = plateChoice == 0;

            String message =
                    "REMOVE LAST SESSION\n\n" +
                    "Latest formal live session: Session " + latest.publicationNumber +
                    "\nJourney ID: " + latest.sessionId +
                    "\nPublication plate completed: " + (publicationPlateCompleted ? "YES" : "NO") +
                    "\n\nThis will restore the replay/publication state to " + previousLabel + ".\n" +
                    "The current ledger will be rolled back surgically; the older output backup is fallback only.\n" +
                    "A timestamped recovery ZIP will be created first.\n" +
                    "The previous-session preamble source will be preserved.\n\n" +
                    "Type REMOVE to continue:";
            String typed = JOptionPane.showInputDialog(this, message,
                    "Confirm removal of Session " + latest.publicationNumber,
                    JOptionPane.WARNING_MESSAGE);
            if (typed == null || !"REMOVE".equals(typed.trim())) return;

            Path backup = createRemovalRecoveryBackup(cli, latest, publicationPlateCompleted);
            performLatestSessionRemoval(cli, evidence, latest, publicationPlateCompleted);

            // Reload from the authoritative restored ledger.
            Path output = cli.resolve("output.txt");
            if (Files.exists(output)) loadFile(output);

            LatestFormalSession after = findLatestFormalLiveSession(evidence);
            boolean removed = after == null || !latest.sessionId.equals(after.sessionId);
            boolean previousRestored = after != null &&
                    after.publicationNumber == latest.publicationNumber - 1;
            if (!removed || !previousRestored)
                throw new IOException("Post-removal integrity check failed. Recovery backup retained at: " + backup);

            JOptionPane.showMessageDialog(this,
                    "Session " + latest.publicationNumber + " removed successfully.\n" +
                    "Journey ID " + latest.sessionId + " is absent.\n" +
                    "Latest formal session is now Session " + after.publicationNumber +
                    " (" + after.sessionId + ").\n\n" +
                    "Recovery backup:\n" + backup,
                    "Remove Last Session — PASS", JOptionPane.INFORMATION_MESSAGE);
        } catch (Exception ex) {
            JOptionPane.showMessageDialog(this,
                    "Removal stopped:\n" + ex.getMessage() +
                    "\n\nNo further session will be removed.",
                    "Remove Last Session — STOP", JOptionPane.ERROR_MESSAGE);
        }
    }

    private static class LatestFormalSession {
        String sessionId;
        int publicationNumber;
        Path liveFile;
    }

    private LatestFormalSession findLatestFormalLiveSession(Path evidence) throws IOException {
        if (!Files.isDirectory(evidence)) return null;
        Pattern fn = Pattern.compile("blackjack_live_session_(\\d{8}_\\d{6})(?:_CORRECTED_EVIDENCE)?\\.txt",
                Pattern.CASE_INSENSITIVE);
        Map<String,Path> byId = new TreeMap<>();
        try (DirectoryStream<Path> ds = Files.newDirectoryStream(evidence, "blackjack_live_session_*.txt")) {
            for (Path p : ds) {
                Matcher m = fn.matcher(p.getFileName().toString());
                if (!m.matches()) continue;
                String id = m.group(1);
                Path prior = byId.get(id);
                if (prior == null || p.getFileName().toString().contains("_CORRECTED_EVIDENCE"))
                    byId.put(id, p);
            }
        }
        if (byId.isEmpty()) return null;
        String id = ((TreeMap<String,Path>)byId).lastKey();
        LatestFormalSession out = new LatestFormalSession();
        out.sessionId = id;
        out.liveFile = byId.get(id);

        // Publication number is chronology among retained formal live sessions,
        // excluding the known non-publication legacy capture.
        int n=0;
        for (String sid : byId.keySet()) {
            if ("20260903_135610".equals(sid)) continue;
            n++;
            if (sid.equals(id)) out.publicationNumber=n;
        }
        return out;
    }

    private Path createRemovalRecoveryBackup(Path cli, LatestFormalSession latest, boolean publicationPlateCompleted) throws IOException {
        Path backups = cli.resolve("backups");
        Files.createDirectories(backups);
        String stamp = new java.text.SimpleDateFormat("yyyyMMdd_HHmmss").format(new java.util.Date());
        Path zip = backups.resolve("REMOVE_LAST_SESSION_S" + latest.publicationNumber +
                "_" + latest.sessionId + "_" + stamp + ".zip");
        try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(
                new BufferedOutputStream(Files.newOutputStream(zip, StandardOpenOption.CREATE_NEW)))) {
            addTreeToRecoveryZip(zos, cli.resolve("output.txt"), cli, "CLI/");
            addTreeToRecoveryZip(zos, cli.resolve("AIplayer.txt"), cli, "CLI/");
            addTreeToRecoveryZip(zos, cli.resolve("session_evidence"), cli, "CLI/");
            addTreeToRecoveryZip(zos, cli.resolve("backups"), cli, "CLI/",
                    p -> !p.equals(zip));
            // Record the operator-declared publication state inside the recovery ZIP.
            zos.putNextEntry(new java.util.zip.ZipEntry("REMOVE_LAST_SESSION_METADATA.txt"));
            String meta = "Session=" + latest.publicationNumber + "\nJourneyID=" + latest.sessionId +
                    "\nPublicationPlateCompleted=" + (publicationPlateCompleted ? "YES" : "NO") + "\n";
            zos.write(meta.getBytes(StandardCharsets.UTF_8));
            zos.closeEntry();
        }
        return zip;
    }

    private interface PathFilter { boolean include(Path p); }
    private void addTreeToRecoveryZip(java.util.zip.ZipOutputStream zos, Path p, Path base,
                                      String prefix) throws IOException {
        addTreeToRecoveryZip(zos,p,base,prefix,x->true);
    }
    private void addTreeToRecoveryZip(java.util.zip.ZipOutputStream zos, Path p, Path base,
                                      String prefix, PathFilter filter) throws IOException {
        if (!Files.exists(p)) return;
        if (Files.isDirectory(p)) {
            try (java.util.stream.Stream<Path> st = Files.walk(p)) {
                for (Iterator<Path> it=st.filter(Files::isRegularFile).iterator();it.hasNext();) {
                    Path f=it.next(); if(!filter.include(f)) continue;
                    String name=prefix+base.relativize(f).toString().replace('\\','/');
                    zos.putNextEntry(new java.util.zip.ZipEntry(name));
                    Files.copy(f,zos); zos.closeEntry();
                }
            }
        } else {
            if(!filter.include(p)) return;
            String name=prefix+base.relativize(p).toString().replace('\\','/');
            zos.putNextEntry(new java.util.zip.ZipEntry(name));
            Files.copy(p,zos); zos.closeEntry();
        }
    }

    private void performLatestSessionRemoval(Path cli, Path evidence, LatestFormalSession latest, boolean publicationPlateCompleted) throws IOException {
        // v15.10.250: surgical rollback first.  Removing only the latest SID from the
        // current ledger preserves the previous session's completed S/publication state.
        // The older previous-session outputbackup is fallback only if surgical rollback
        // cannot produce a valid retained previous formal session.
        Path output=cli.resolve("output.txt");
        String currentText=Files.readString(output,StandardCharsets.UTF_8);
        String surgical=removeSessionOwnedOutput(currentText,latest.sessionId);
        boolean surgicalOk=!surgical.contains(latest.sessionId);
        if (surgicalOk) {
            Files.writeString(output,surgical,StandardCharsets.UTF_8,
                    StandardOpenOption.TRUNCATE_EXISTING,StandardOpenOption.CREATE);
        } else {
            Path previousBackup = null;
            if (latest.publicationNumber > 1) {
                String prefix = "outputbackup_SESSION" + (latest.publicationNumber - 1) + "_";
                Path backups = cli.resolve("backups");
                if (Files.isDirectory(backups)) {
                    try (DirectoryStream<Path> ds = Files.newDirectoryStream(backups, prefix + "*.txt")) {
                        for (Path p : ds)
                            if (previousBackup == null || p.getFileName().toString().compareTo(previousBackup.getFileName().toString()) > 0)
                                previousBackup=p;
                    }
                }
            }
            if (previousBackup == null)
                throw new IOException("Surgical ledger rollback failed and no previous-session output backup exists.");
            Files.copy(previousBackup, output, StandardCopyOption.REPLACE_EXISTING);
        }

        // Remove only latest-session-owned derived evidence/publication artifacts.
        // PREAMBLE source files have their own PREVIOUS_SESSION_* identity and are preserved.
        if (Files.isDirectory(evidence)) {
            String sid=latest.sessionId;
            String sNo="Session"+latest.publicationNumber;
            try (DirectoryStream<Path> ds=Files.newDirectoryStream(evidence)) {
                for(Path p:ds){
                    if(!Files.isRegularFile(p))continue;
                    String n=p.getFileName().toString();
                    boolean owned=n.contains(sid) || n.toLowerCase(Locale.ROOT).contains(sNo.toLowerCase(Locale.ROOT));
                    boolean preamble=n.toLowerCase(Locale.ROOT).contains("preamble_previous_session");
                    if(owned && !preamble) Files.deleteIfExists(p);
                }
            }
            // Generic current-publication handoff files are session-owned outputs of S;
            // remove them so the previous session's next L/S publication gate is rebuilt cleanly.
            for(String n:new String[]{"CHATGPT_HANDOFF_MANIFEST.txt","ChatGPT_Session"+latest.publicationNumber+"_Publication_Handoff.zip"})
                Files.deleteIfExists(evidence.resolve(n));

            // Canonical accepted plate belongs to the removed publication session.
            // The explicit YES/NO prompt prevents silently assuming publication status.
            if (publicationPlateCompleted) {
                Path plate=evidence.resolve("plates").resolve("Session"+latest.publicationNumber+".png");
                Files.deleteIfExists(plate);
            }
        }
    }

    private String removeSessionOwnedOutput(String text,String sid){
        String[] lines=text.split("\\R",-1); StringBuilder out=new StringBuilder();
        boolean skipping=false; int depth=0;
        Pattern start=Pattern.compile("^=+.*(?:SESSION|\\| SESSION)\\s+"+Pattern.quote(sid)+".*=+\\s*$",
                Pattern.CASE_INSENSITIVE);
        Pattern end=Pattern.compile("^=+\\s*END .*SESSION.*"+Pattern.quote(sid)+".*=+\\s*$",
                Pattern.CASE_INSENSITIVE);
        for(String line:lines){
            if(!skipping && start.matcher(line).matches()){skipping=true;depth=1;continue;}
            if(skipping){if(end.matcher(line).matches()){skipping=false;depth=0;}continue;}
            if(line.contains(sid)) continue;
            out.append(line).append(System.lineSeparator());
        }
        return out.toString();
    }

    private void chooseFile() {
        JFileChooser fc = new JFileChooser(); fc.setDialogTitle("Select blackjack output.txt");
        if (fc.showOpenDialog(this)==JFileChooser.APPROVE_OPTION) loadFile(fc.getSelectedFile().toPath());
    }

    private String readSessionEvidenceIfPresent(String sid) {
        if (sid == null || sid.isBlank()) return "";
        Path root = dataDirectory().resolve("session_evidence");
        String[] names = {
            "blackjack_live_session_" + sid + "_CORRECTED_EVIDENCE.txt",
            "blackjack_live_session_" + sid + ".txt",
            "blackjack_shuffle_analysis_" + sid + ".txt"
        };
        StringBuilder out = new StringBuilder();
        // v15.10.216: correction artifacts are session-scoped authority, never global extras.
        List<String> scopedNames = new ArrayList<>(Arrays.asList(names));
        if ("20260917_183148".equals(sid)) scopedNames.add("blackjack_session13_unknown_suit_reconstruction_fix_V9.2.53.txt");
        for (String name : scopedNames) {
            Path f = root.resolve(name);
            if (!Files.exists(f)) continue;
            try {
                String t = Files.readString(f, StandardCharsets.UTF_8);
                if (out.length() > 0) out.append('\n');
                out.append(t);
            } catch (Exception ignored) {}
        }
        return out.toString();
    }

    private String comparisonEvidenceText(String sid) {
        if(currentSession!=null && currentSession.standalonePreamble && sid!=null && sid.equals(currentSession.id)){
            Path p=dataDirectory().resolve("session_evidence").resolve("blackjack_preamble_"+sid+".txt");
            if(Files.isRegularFile(p))try{return Files.readString(p,StandardCharsets.UTF_8);}catch(Exception ignored){}
        }
        String extra = readSessionEvidenceIfPresent(sid);
        if (extra.isBlank()) return loadedOutputText;
        // Corrected dedicated evidence is chronology authority for reconstruction.
        // Do not concatenate it behind output.txt: dedicated reports have no cumulative SESSION wrapper.
        return extra;
    }

    private String controllerTraceEvidenceText(String sid) {
        StringBuilder out=new StringBuilder(loadedOutputText==null?"":loadedOutputText);
        if(sid==null||sid.isBlank())return out.toString();
        Path f=dataDirectory().resolve("session_evidence").resolve("blackjack_controller_trace_backfill_"+sid+".txt");
        if(Files.exists(f))try{out.append('\n').append(Files.readString(f,StandardCharsets.UTF_8));}catch(Exception ignored){}
        return out.toString();
    }

    private String ultimateAIEvidenceText(String sid) {
        StringBuilder out = new StringBuilder(loadedOutputText == null ? "" : loadedOutputText);
        if (sid == null || sid.isBlank()) return out.toString();
        Path root = dataDirectory().resolve("session_evidence");
        String[] names = {
            "blackjack_shuffle_analysis_" + sid + ".txt",
            "blackjack_ultimate_ai_audit_" + sid + ".txt",
            "blackjack_ultimate_ai_completion_correction_" + sid + ".txt",
            "blackjack_controller_trace_backfill_" + sid + ".txt",
            // v15.10.252: version-matched historical reconstruction, when explicitly retained.
            // Appended after diagnostic backfill so buildUltimateTrace sees the historically
            // authoritative rich trace rather than a later cross-version diagnostic trace.
            "blackjack_controller_trace_historical_" + sid + ".txt",
            "blackjack_controller_trace_historical_suits_" + sid + ".txt"
        };
        for (String name : names) {
            Path f = root.resolve(name);
            if (!Files.exists(f)) continue;
            try { out.append('\n').append(Files.readString(f, StandardCharsets.UTF_8)); } catch (Exception ignored) {}
        }
        return out.toString();
    }

    private void loadFile(Path path) {
        try {
            String text = Files.readString(path, StandardCharsets.UTF_8);
            loadedOutputText = text;
            List<Session> parsed = Parser.parse(text);
            if (parsed.isEmpty()) throw new IOException("No completed session blocks with HAND summary records were found.");
            // v15.10.113-UAT: Session comments may live only in the dedicated live-session
            // evidence report rather than cumulative output.txt. Supplement notes from that
            // authoritative source without changing any hand/card/outcome evidence.
            for (Session s : parsed) {
                String extra = readSessionEvidenceIfPresent(s.id);
                if (!extra.isBlank()) {
                    Parser.attachStructuredMindsetEvents(extra, java.util.List.of(s));
                    // v15.10.344-UAT: dedicated live evidence is authoritative for operator session names.
                    Matcher en=Pattern.compile("(?im)^SESSION\s+NAME\s*:\s*(.+)$").matcher(extra);
                    if(en.find()) s.sessionName=en.group(1).trim();
                    Matcher ec=Pattern.compile("(?im)^SESSION\s+CLASSIFICATION\s*:\s*(.+)$").matcher(extra);
                    if(ec.find()) s.sessionClassification=ec.group(1).trim();
                }
                // v15.10.341-UAT: scope cumulative evidence to this exact session to prevent cross-session provenance leakage.
                String envEvidence = extra.isBlank() ? sessionEvidenceBlock(text, s.id) : extra;
                populateEnvironmentEvidence(s, envEvidence);
                if(isEngineeringValidationSession(s)) s.envCapture="Synthetic / controlled UAT";
            }
            sessions.clear(); sessions.addAll(parsed);
            replayCatalog.clear(); replayCatalog.addAll(parsed);
            // v15.10.363-UAT: derived research replay.  Session 14 remains immutable; this
            // catalogue-only twin replays the same retained chronology through the frozen
            // Phase 10E-I prospective evidence chain with no later-session/corpus input.
            // v15.10.368-UAT: preserve the accepted Session 14 isolated V2 replay and
            // add Session 17 as the next individually examined Paddy Power session.
            // Each derived replay sees only evidence accumulated earlier inside that same
            // source session; no later-session/corpus evidence is admitted at this stage.
            for (Session base : parsed) {
                if (base.publicationNumber==14 || "20260920_003052".equals(base.id)
                        || base.publicationNumber==17 || "20261002_152644".equals(base.id)) {
                    replayCatalog.add(cloneAsUltimateV2Replay(base));
                }
            }
            replayCatalog.addAll(loadStandaloneAccountedPreambles());
            sessionBox.removeAllItems();
            for (Session s: replayCatalog) sessionBox.addItem(replaySelectorLabel(s));
            int preferred=0;
            for(int i=0;i<replayCatalog.size();i++) if(replayCatalog.get(i).id.contains("20260903_004704")){preferred=i;break;}
            sessionBox.setSelectedIndex(preferred);
            selectSession(replayCatalog.get(preferred));
            updateCompareAvailability();
            if (!suppressOpeningMemorySplash) showOpeningMemorySplash();
        } catch(Exception ex) {
            JOptionPane.showMessageDialog(this, ex.getMessage(), "Could not load replay", JOptionPane.ERROR_MESSAGE);
        }
    }


    // v15.10.319-UAT: a preamble earns an independent Historical Replay entry only after
    // standalone completion AND explicit post-render publication acceptance.  Linked/pending
    // preambles never enter this catalogue.  This is platform-neutral (247Blackjack/Paddy Power).
    private java.util.List<Session> loadStandaloneAccountedPreambles() {
        java.util.List<Session> out=new ArrayList<>();
        Path root=dataDirectory().resolve("session_evidence");
        if(!Files.isDirectory(root))return out;
        try(DirectoryStream<Path> ds=Files.newDirectoryStream(root,"preamble_independent_completion_*.txt")){
            for(Path completion:ds){
                String ct=Files.readString(completion,StandardCharsets.UTF_8);
                Matcher im=Pattern.compile("(?im)^PREAMBLE_ID:\\s*(\\S+)\\s*$").matcher(ct); if(!im.find())continue;
                String id=im.group(1).trim();
                if(!Pattern.compile("(?im)^STATUS:\\s*S_COMPLETE\\s*/\\s*INDEPENDENT\\s*/").matcher(ct).find())continue;
                Path acceptance=root.resolve("Preamble_"+id+"_PostRender_Acceptance.txt");
                Path evidence=root.resolve("blackjack_preamble_"+id+".txt");
                if(!Files.isRegularFile(acceptance)||!Files.isRegularFile(evidence))continue;
                String at=Files.readString(acceptance,StandardCharsets.UTF_8);
                if(!Pattern.compile("(?im)^PUBLICATION_ACCEPTANCE:\\s*COMPLETE\\s*$").matcher(at).find())continue;
                if(!Pattern.compile("(?im)^PREAMBLE_ID:\\s*"+Pattern.quote(id)+"\\s*$").matcher(at).find())continue;
                String et=Files.readString(evidence,StandardCharsets.UTF_8);
                Session q=Parser.parseSession(id,et); if(q.hands.isEmpty())continue;
                q.standalonePreamble=true; q.hadPreamble=true; q.publicationNumber=0; q.preambleCards=q.hands.stream().mapToInt(h->recordedCardSequence(h).size()).sum();
                Matcher pm=Pattern.compile("(?im)^PREAMBLE_PLATFORM:\\s*(.+)$").matcher(et); if(pm.find())q.platform=pm.group(1).trim();
                Matcher dm=Pattern.compile("(?im)^PREAMBLE DESCRIPTION:\\s*(.+)$").matcher(et); if(dm.find())q.sessionName=dm.group(1).trim();
                Matcher mm=Pattern.compile("(?im)^MODE:\\s*([A-Z_-]+)").matcher(et); if(mm.find())q.architecture=mm.group(1).trim().toUpperCase(Locale.ROOT);
                Matcher cm=Pattern.compile("(?im)^CANONICAL_RESEARCH_FRAME_START:\\s*([0-9.]+).*?^CANONICAL_RESEARCH_FRAME_END:\\s*([0-9.]+)",Pattern.MULTILINE|Pattern.DOTALL).matcher(ct);
                double canonicalStart=100.0,canonicalEnd=Double.NaN; if(cm.find()){canonicalStart=parseMoney(cm.group(1));canonicalEnd=parseMoney(cm.group(2));}
                Matcher am=Pattern.compile("(?im)^DECLARED_RESEARCH_BANKROLL_ACTUAL:\\s*([0-9.]+).*?^CLOSING_RESEARCH_BANKROLL_ACTUAL:\\s*([0-9.]+)",Pattern.MULTILINE|Pattern.DOTALL).matcher(ct);
                if(am.find()){q.actualStart=parseMoney(am.group(1));q.actualEnd=parseMoney(am.group(2));}
                if(!q.hands.isEmpty()){
                    double rawStart=parseMoney(q.hands.get(0).startBank); q.offset=rawStart-canonicalStart;
                    for(Hand h:q.hands)h.offset=q.offset;
                    q.summary="£100 research frame £"+money(canonicalStart)+" → £"+money(Double.isNaN(canonicalEnd)?q.hands.get(q.hands.size()-1).researchEnd():canonicalEnd);
                }
                // v15.10.336-UAT: standalone-preamble detailed chunks use PREAMBLE HAND rather than
                // the formal-session HAND n/30 banner. Scope evidence to the individual preamble hand
                // and attach explicit observed split children/results. Never infer a split from Frozen policy.
                attachStandalonePreambleDetailedEvidence(et,q);
                populateEnvironmentEvidence(q, et + "\n" + ct + "\n" + at);
                out.add(q);
            }
        }catch(Exception ex){System.err.println("Standalone preamble Replay discovery warning: "+ex.getMessage());}
        out.sort(Comparator.comparing(a->a.id));
        return out;
    }

    // v15.10.341-UAT: isolate one completed session from cumulative output.txt for environment provenance.
    private static String sessionEvidenceBlock(String text,String sid){
        if(text==null||sid==null||sid.isBlank())return "";
        String start="================ SESSION "+sid+" ================";
        int a=text.indexOf(start); if(a<0)return "";
        String end="================ END SESSION "+sid+" ================";
        int b=text.indexOf(end,a); if(b<0)return text.substring(a);
        return text.substring(a,Math.min(text.length(),b+end.length()));
    }

    // v15.10.339-UAT: pre-play environment facts are evidence-derived only. Missing facts stay UNKNOWN.
    private static String firstEvidence(String text,String... regexes){
        if(text==null)return "";
        for(String rx:regexes){Matcher m=Pattern.compile(rx,Pattern.CASE_INSENSITIVE|Pattern.MULTILINE).matcher(text);if(m.find())return m.group(1).trim();}
        return "";
    }
    private static void populateEnvironmentEvidence(Session s,String evidence){
        if(s==null)return; String e=evidence==null?"":evidence;
        String platform=firstEvidence(e,
            "^PLATFORM DECLARED:\\s*([^|\\r\\n]+)",
            "^SOURCE:\\s*([^|\\r\\n]+)",
            "^PREAMBLE_PLATFORM:\\s*(.+)$");
        if(!platform.isBlank())s.platform=platform;
        String sourceType=firstEvidence(e,"^SOURCE TYPE:\\s*(.+)$");
        if(Pattern.compile("(?i)SCREENSHOT_RECONSTRUCTION|screenshot-reconstructed|OBSERVED_SCREENSHOT").matcher(e).find())s.envCapture="Screenshot reconstructed";
        else if(!sourceType.isBlank())s.envCapture=sourceType;
        else if(Pattern.compile("(?i)Manual external-session entry").matcher(e).find())s.envCapture="Manual external-session entry";

        String ps=firstEvidence(e,
            "SESSION START BALANCE CONFIRMED:\\s*Platform\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "REAL CASINO SCALE\\s*\\|[^\\r\\n]*?actual platform start\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "DECLARED EXECUTION SCALE\\s*\\|[^\\r\\n]*?actual platform start\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "PREAMBLE START BALANCE(?: CONFIRMED)?[^\\r\\n]*?Platform\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "Manual external-session entry\\s*\\|\\s*Platform start\\s*£?([0-9]+(?:\\.[0-9]+)?)");
        if(!ps.isBlank())s.envPlatformStart="£"+money(parseMoney(ps));

        String sb=firstEvidence(e,
            "SESSION START BALANCE CONFIRMED:[^\\r\\n]*?declared session bankroll\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "DECLARED SCALE LOCKED BEFORE HAND 1\\s*\\|\\s*bankroll\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "EXECUTION SCALE:[^\\r\\n]*?experimental bankroll\\s*£?([0-9]+(?:\\.[0-9]+)?)",
            "PREAMBLE START BALANCE(?: CONFIRMED)?[^\\r\\n]*?research-equivalent\\s*£?([0-9]+(?:\\.[0-9]+)?)");
        if(!sb.isBlank())s.envSessionBankroll="£"+money(parseMoney(sb));
        else if(s.standalonePreamble && !Double.isNaN(s.actualStart))s.envSessionBankroll="£"+money(s.actualStart);

        String scale=firstEvidence(e,
            "DECLARED SCALE LOCKED BEFORE HAND 1[^\\r\\n]*?scale\\s*(1:[0-9]+)",
            "EXECUTION SCALE:[^\\r\\n]*?\\|\\s*(1:[0-9]+)\\s*\\|",
            "REAL CASINO SCALE\\s*\\|[^\\r\\n]*?\\|\\s*(1:[0-9]+)\\s*\\|");
        if(!scale.isBlank())s.envScale=scale;
        else if(!s.envSessionBankroll.equals("UNKNOWN")){double b=parseMoney(s.envSessionBankroll);if(b>0)s.envScale="£100 → £"+money(b);}

        s.envPreambleStatus=s.standalonePreamble?"Standalone-accounted":(s.hadPreamble?"Linked":"None");
        if(!s.hands.isEmpty())s.envShuffleStart=s.hands.get(0).shuffleBefore?"Observed":"Not recorded";
        s.envPlate=s.standalonePreamble?("Preamble_"+s.id+".png"):(s.publicationNumber>0?"Session"+s.publicationNumber+".png":"UNKNOWN");
    }

    private static boolean isEngineeringValidationSession(Session s){
        if(s==null||s.standalonePreamble)return false;
        if("ENGINEERING_UAT".equalsIgnoreCase(s.sessionClassification))return true;
        // v15.10.377-UAT: operator-confirmed provenance boundary. Session 15 was the last
        // real-data development-era session; retained formal Sessions 16-67 were controlled
        // engineering/UAT cardstreams. This is display/statistical provenance only: no cards,
        // outcomes, balances or original evidence files are rewritten. Future sessions use the
        // explicit EVIDENCE SOURCE field written by the CLI rather than this legacy boundary.
        if(s.publicationNumber>=16 && s.publicationNumber<=67)return true;
        String n=s.sessionName==null?"":s.sessionName.toUpperCase(Locale.ROOT);
        return n.contains("UAT") || n.contains("BENCHMARK") ||
               n.contains("VALIDATION") || n.contains("ROLLBACK_MODE_STATE_REPAIR") ||
               n.contains("PRESENTATION_REPAIR") || n.contains("LIVE_TRACE_UAT");
    }

    // v15.10.345-UAT: Engineering/UAT synthetic validation sessions remain replayable audit evidence
    // but are excluded from all observational/statistical populations and all-session summaries.
    // v15.10.352-UAT: semantic quarantine is an authority boundary, not a deletion rule.
    // Quarantined retained sessions remain replayable for forensic/engineering inspection,
    // but can never contribute to observational statistics or all-session summaries.
    private static boolean isSemanticQuarantined(Session s){
        if(s==null||s.id==null||s.id.isBlank())return false;
        Path q=Paths.get("UNIVERSAL_EVIDENCE_SEMANTIC_QUARANTINE.tsv");
        if(!Files.isRegularFile(q))return false;
        String needle="blackjack_live_session_"+s.id+".txt\t";
        try{for(String line:Files.readAllLines(q,StandardCharsets.UTF_8))if(line.startsWith(needle))return true;}catch(Exception ignored){}
        return false;
    }

    // v15.10.354-UAT: quarantine remains visible during hand playback and the exact
    // post-terminal dealer card is identified without rewriting retained evidence.
    private static boolean dealerTerminalBeforeHiddenIndex(Hand h,int hiddenIndex){
        if(h==null||hiddenIndex<0)return false;
        List<String> cards=new ArrayList<>();
        if(h.dealerUp!=null&&!h.dealerUp.isBlank())cards.add(h.dealerUp);
        for(int i=0;i<hiddenIndex && i<h.dealerHidden.size();i++)cards.add(h.dealerHidden.get(i));
        if(cards.size()<2)return false;
        int t=cardTotal(cards);
        return t>=17; // project dealer authority is S17; 17-21 and bust are terminal.
    }
    private static boolean hasDealerPostTerminalEvidence(Hand h){
        if(h==null)return false;
        for(int i=0;i<h.dealerHidden.size();i++)if(dealerTerminalBeforeHiddenIndex(h,i))return true;
        return false;
    }

    private static boolean isObservationalStatisticsEligible(Session s){
        return s!=null && !isEngineeringValidationSession(s) && !isSemanticQuarantined(s);
    }

    private static boolean isHistoricalSummaryEligible(Session s){
        // v15.10.376-UAT: the all-session historical summary is the frozen publication-era
        // evidence view (Sessions 1-14). Later Ultimate-AI/engineering sessions remain
        // individually replayable but do not leak into historical cumulative populations.
        return isObservationalStatisticsEligible(s) && s.publicationNumber>0 && s.publicationNumber<=14;
    }

    private java.util.List<Session> observationalSessions(){
        java.util.List<Session> out=new ArrayList<>();
        for(Session s:sessions) if(isHistoricalSummaryEligible(s)) out.add(s);
        return out;
    }

    private static String engineeringShortLabel(Session s){
        String n=s==null||s.sessionName==null?"":s.sessionName.trim();
        if(n.isBlank())return "";
        n=n.replace('_',' ').trim();
        // v15.10.379-UAT: Replay owns the canonical session identity and UAT class.
        // Historical operator descriptions may themselves contain "Session N", "UAT" or
        // "ENGINEERING UAT".  Remove those provenance/identity tokens from the free-text
        // description so each rendered entry contains them exactly once.
        if(s!=null && s.publicationNumber>0){
            String q=Pattern.quote(Integer.toString(s.publicationNumber));
            n=n.replaceAll("(?i)^\\s*Session\\s*"+q+"\\s*(?:[-—–:|]+\\s*)?","");
            n=n.replaceAll("(?i)^\\s*Session"+q+"\\s*(?:[-—–:|]+\\s*)?","");
        }
        n=n.replaceAll("(?i)\\bENGINEERING\\s+UAT\\b","");
        n=n.replaceAll("(?i)\\bUAT\\b","");
        n=n.replaceAll("\\s+"," ").trim();
        n=n.replaceAll("^[\\s\\-—–:|•]+","").replaceAll("[\\s\\-—–:|•]+$","").trim();
        return n;
    }

    private static String replaySelectorLabel(Session s){
        if(s!=null&&s.ultimateV2Replay) return "Session "+s.publicationNumber+" - Ultimate AI V2 Replay  •  "+s.hands.size()+" "+(s.hands.size()==1?"hand":"hands")+"  •  ISOLATED / NO CORPUS";
        if(s!=null&&s.standalonePreamble){
            String platform=(s.platform==null||s.platform.isBlank())?"UNKNOWN":s.platform;
            String provenance=platform;
            if(("UNKNOWN".equalsIgnoreCase(platform)||"OTHER_UNKNOWN".equalsIgnoreCase(platform))&&s.architecture!=null&&!s.architecture.isBlank()) provenance=s.architecture.replace('_',' ');
            return "PRE "+s.id+"  •  "+s.hands.size()+" "+(s.hands.size()==1?"hand":"hands")+"  •  "+provenance;
        }
        if(isEngineeringValidationSession(s)){
            String label=engineeringShortLabel(s);
            String identity="ENGINEERING UAT  •  Session "+s.publicationNumber;
            if(!label.isBlank()) identity += " - "+label;
            return identity+"  •  "+s.hands.size()+" "+(s.hands.size()==1?"hand":"hands");
        }
        if(s!=null && s.publicationNumber>0) return "SESSION "+s.publicationNumber+"  •  "+s.id+"  •  "+s.hands.size()+" "+(s.hands.size()==1?"hand":"hands");
        return s.id+"  •  "+s.hands.size()+" hands";
    }

    private void showOpeningMemorySplash() {
        java.util.List<Session> observational = observationalSessions();
        java.util.List<OpeningFrequency> top = openingFrequencies(observational);
        JDialog d = new JDialog(this, "Most Frequent Openings • v" + APP_VERSION, true);
        d.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        d.setMinimumSize(new Dimension(980, 560));
        Dimension screen = Toolkit.getDefaultToolkit().getScreenSize();
        int splashW = Math.min(1180, Math.max(980, screen.width - 140));
        int splashH = Math.min(790, Math.max(620, screen.height - 150));
        d.setSize(splashW, splashH);
        d.setLocationRelativeTo(this);

        JPanel root = new JPanel(new BorderLayout(0, 14));
        root.setBackground(new Color(248, 250, 252));
        root.setBorder(new EmptyBorder(22, 26, 20, 26));

        int totalHands = totalHandsDealt(observational);
        double avgDecisionsPerSession = averageDecisionsPerSession(observational);

        JPanel head = new JPanel(new BorderLayout(18, 0));
        head.setOpaque(false);

        JPanel titleBlock = new JPanel();
        titleBlock.setOpaque(false);
        titleBlock.setLayout(new BoxLayout(titleBlock, BoxLayout.Y_AXIS));
        JLabel title = new JLabel("MOST FREQUENT OPENINGS  •  v" + APP_VERSION);
        title.setFont(new Font("SansSerif", Font.BOLD, 24));
        title.setForeground(NAVY);
        title.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel sub = new JLabel("Memory lane • repeated player total vs dealer upcard • 2+ recorded occurrences");
        sub.setFont(new Font("SansSerif", Font.PLAIN, 13));
        sub.setForeground(new Color(80, 92, 104));
        sub.setAlignmentX(Component.LEFT_ALIGNMENT);
        titleBlock.add(title); titleBlock.add(Box.createVerticalStrut(3)); titleBlock.add(sub);
        head.add(titleBlock, BorderLayout.CENTER);

        JPanel metrics = new JPanel(new GridLayout(1, 2, 10, 0));
        metrics.setOpaque(false);
        metrics.add(openingMetricCard("AVG DECISIONS / SESSION", String.format(Locale.ROOT, "%.2f", avgDecisionsPerSession)));
        metrics.add(openingMetricCard("TOTAL HANDS DEALT", String.valueOf(totalHands)));
        head.add(metrics, BorderLayout.EAST);
        root.add(head, BorderLayout.NORTH);

        JPanel list = new JPanel();
        list.setOpaque(false);
        list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
        if (top.isEmpty()) {
            JLabel empty = new JLabel("No opening state has yet been recorded twice.");
            empty.setFont(new Font("SansSerif", Font.BOLD, 14));
            empty.setForeground(new Color(75, 85, 95));
            empty.setBorder(new EmptyBorder(28, 4, 28, 4));
            list.add(empty);
        } else {
            JPanel hdr = openingHeaderRow();
            list.add(hdr);
            list.add(Box.createVerticalStrut(4));
            for (int i = 0; i < top.size(); i++) {
                list.add(openingRow(i + 1, top.get(i)));
                if (i + 1 < top.size()) list.add(Box.createVerticalStrut(3));
            }
        }
        JScrollPane sp = new JScrollPane(list);
        sp.setBorder(BorderFactory.createEmptyBorder());
        sp.getViewport().setBackground(root.getBackground());
        sp.getVerticalScrollBar().setUnitIncrement(18);
        sp.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        // v15.10.160-UAT: compact Memory Lane so the right Stats Corner keeps usable width.
        // Headers wrap; smaller left-aligned cells retain all four values plus scrollbar.
        sp.setMinimumSize(new Dimension(220, 0));
        sp.setPreferredSize(new Dimension(240, 520));

        // v15.10.11: Memory Lane is intentionally narrower so the statistics area
        // remains readable as the number of sessions grows.
        JPanel centre = new JPanel(new GridBagLayout());
        centre.setOpaque(false);
        GridBagConstraints lc = new GridBagConstraints();
        lc.gridx=0; lc.gridy=0; lc.weightx=0.23; lc.weighty=1.0; lc.fill=GridBagConstraints.BOTH; lc.insets=new Insets(0,0,0,18);
        centre.add(sp, lc);
        GridBagConstraints rc = new GridBagConstraints();
        rc.gridx=1; rc.gridy=0; rc.weightx=0.77; rc.weighty=1.0; rc.fill=GridBagConstraints.BOTH;
        centre.add(buildFascinatingStatsCorner(), rc);
        root.add(centre, BorderLayout.CENTER);

        JButton enter = new JButton("OPEN SESSION REPLAYS");
        enter.setFont(new Font("SansSerif", Font.BOLD, 13));
        enter.setPreferredSize(new Dimension(210, 38));
        enter.addActionListener(e -> d.dispose());
        JPanel foot = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
        foot.setOpaque(false); foot.add(enter); root.add(foot, BorderLayout.SOUTH);

        d.setContentPane(root);
        SwingUtilities.invokeLater(() -> d.setVisible(true));
    }


    public void showStatsCornerDialog(Component owner) {
        if (sessions.isEmpty()) {
            JOptionPane.showMessageDialog(owner, "No session evidence is loaded yet.", "Stats Corner", JOptionPane.INFORMATION_MESSAGE);
            return;
        }
        Window ownerWindow = owner == null ? this : SwingUtilities.getWindowAncestor(owner);
        JDialog d = new JDialog(ownerWindow, "Fascinating Stats Corner • v" + APP_VERSION, Dialog.ModalityType.DOCUMENT_MODAL);
        d.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        d.setMinimumSize(new Dimension(620, 620));
        Dimension screen = Toolkit.getDefaultToolkit().getScreenSize();
        d.setSize(Math.min(820, Math.max(680, screen.width - 300)), Math.min(860, Math.max(680, screen.height - 140)));

        JPanel root = new JPanel(new BorderLayout(0, 8));
        root.setBackground(new Color(248, 250, 252));
        root.setBorder(new EmptyBorder(12, 12, 10, 12));
        root.add(buildFascinatingStatsCorner(), BorderLayout.CENTER);

        JButton close = new JButton("RETURN");
        close.setFont(new Font("SansSerif", Font.BOLD, 11));
        close.addActionListener(e -> d.dispose());
        JPanel foot = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
        foot.setOpaque(false);
        foot.add(close);
        root.add(foot, BorderLayout.SOUTH);

        d.setContentPane(root);
        d.setLocationRelativeTo(owner == null ? this : owner);
        d.setVisible(true);
    }

    public static void openStatsCornerFromLive(Component owner) {
        BlackjackSessionReplay host = new BlackjackSessionReplay(true);
        host.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        try {
            host.showStatsCornerDialog(owner);
        } finally {
            host.dispose();
        }
    }


    private static class SessionSummaryRow {
        String player="",hands="N/A",finalBank="N/A",peak="N/A",trough="N/A",exposure="N/A",avgWager="N/A",samePL="N/A",diffPL="N/A",termination="N/A";
        Object[] cells(){return new Object[]{player,hands,finalBank,peak,trough,exposure,avgWager,samePL,diffPL,termination};}
    }

    public void showSessionSummaryDialog(Component owner) {
        if (sessions.isEmpty()) {
            JOptionPane.showMessageDialog(owner, "No session evidence is loaded yet.", "Session Summary", JOptionPane.INFORMATION_MESSAGE);
            return;
        }
        Window ow = owner == null ? this : SwingUtilities.getWindowAncestor(owner);
        JDialog d = new JDialog(ow, "All-Session Evidence Summary • v" + APP_VERSION, Dialog.ModalityType.DOCUMENT_MODAL);
        d.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        Rectangle usableSummary = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
        d.setSize(Math.max(1050, usableSummary.width-24), Math.max(700, usableSummary.height-24));

        JPanel root=new JPanel(new BorderLayout(0,8));root.setBackground(new Color(248,250,252));root.setBorder(new EmptyBorder(5,10,5,10));
        JPanel head=new JPanel();head.setOpaque(false);head.setLayout(new BoxLayout(head,BoxLayout.Y_AXIS));
        JLabel t=new JLabel("HISTORICAL EVIDENCE • ALL-PLAYER SUMMARY");t.setFont(new Font("SansSerif",Font.BOLD,19));t.setForeground(NAVY);t.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel sub=new JLabel("Source-supported only • each session has one FORMAL START table and, where evidence exists, a separate PREAMBLE START table");sub.setFont(new Font("SansSerif",Font.PLAIN,11));sub.setForeground(new Color(90,101,111));sub.setAlignmentX(Component.LEFT_ALIGNMENT);
        head.add(t);head.add(Box.createVerticalStrut(2));head.add(sub);head.add(Box.createVerticalStrut(9));
        head.add(buildAllSessionRunningProfitBar());
        root.add(head,BorderLayout.NORTH);

        JPanel body=new ViewportWidthPanel();body.setLayout(new BoxLayout(body,BoxLayout.Y_AXIS));body.setBackground(Color.WHITE);body.setBorder(new EmptyBorder(8,8,8,8));
        for(Session ss:sessions){
            if(!isHistoricalSummaryEligible(ss)||ss.hands.isEmpty())continue;
            JLabel sh=new JLabel("SESSION "+ss.publicationNumber+" — "+(ss.sessionName==null||ss.sessionName.isBlank()?ss.id:ss.sessionName));sh.setFont(new Font("SansSerif",Font.BOLD,14));sh.setForeground(NAVY);sh.setAlignmentX(Component.LEFT_ALIGNMENT);body.add(sh);body.add(Box.createVerticalStrut(4));
            body.add(summarySection("FORMAL START", formalSummaryRows(ss)));body.add(Box.createVerticalStrut(7));
            java.util.List<SessionSummaryRow> pre=preambleSummaryRows(ss);if(!pre.isEmpty()){body.add(summarySection("PREAMBLE START — COUNTERFACTUAL CONTEXT • NOT FORMAL SESSION RESULTS",pre));body.add(Box.createVerticalStrut(7));}
            body.add(new JSeparator());body.add(Box.createVerticalStrut(10));
        }
        JLabel note=new JLabel("<html><div style='width:1000px'><b>Evidence rules:</b> Final bankroll is shown only for a completed/source-complete journey. A source-exhausted row may show its last supported bankroll in parentheses but is not promoted to a final result. SAME/DIFFERENT P/L uses the start-of-hand Frozen decision-point classification on mutually source-supported hands only. Exposure and average wager remain separate measures; N/A is retained where the required trace is not present.</div></html>");
        note.setFont(new Font("SansSerif",Font.PLAIN,10));note.setForeground(new Color(80,91,101));note.setAlignmentX(Component.LEFT_ALIGNMENT);body.add(note);
        JScrollPane sp=new JScrollPane(body);
        // v15.10.136-UAT: one page-level vertical navigator only; never create horizontal element scrolling.
        // v15.10.136-UAT: All-Session termination/evidence column widened so long source-boundary labels are fully visible.
        sp.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        sp.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED);
        sp.getVerticalScrollBar().setUnitIncrement(22);root.add(sp,BorderLayout.CENTER);
        JButton close=new JButton("RETURN");close.addActionListener(e->d.dispose());JPanel foot=new JPanel(new FlowLayout(FlowLayout.RIGHT,0,0));foot.setOpaque(false);foot.add(close);root.add(foot,BorderLayout.SOUTH);
        d.setContentPane(root);d.setLocationRelativeTo(owner==null?this:owner);d.setVisible(true);
    }


    private static class RunningPLTotal {
        double value=0.0; int supported=0, excluded=0; String note="";
    }

    private JComponent buildAllSessionRunningProfitBar(){
        RunningPLTotal observed=new RunningPLTotal(), frozen=new RunningPLTotal(), casual=new RunningPLTotal(), stat=new RunningPLTotal(), ai=new RunningPLTotal();
        for(Session s:sessions){
            if(!isHistoricalSummaryEligible(s)||s.hands==null||s.hands.isEmpty())continue;
            // Observed/live journey is authoritative evidence for what actually happened, irrespective of controller mode.
            try{double end=s.hands.get(s.hands.size()-1).researchEnd();observed.value+=end-100.0;observed.supported++;}catch(Exception ex){observed.excluded++;}

            String arch=s.architecture==null?"":s.architecture.toUpperCase(Locale.ROOT);
            if(arch.contains("FROZEN")){
                try{double end=s.hands.get(s.hands.size()-1).researchEnd();frozen.value+=end-100.0;frozen.supported++;}catch(Exception ex){frozen.excluded++;}
            }else if(arch.contains("HYBRID")){
                try{
                    java.util.List<CompareHand> ft=buildFrozenTrace(comparisonEvidenceText(s.id),s.id);
                    if(ft!=null&&!ft.isEmpty()&&ft.size()>=30){frozen.value+=ft.get(ft.size()-1).after-100.0;frozen.supported++;}
                    else if(ft!=null&&!ft.isEmpty())frozen.excluded++;
                }catch(Exception ex){frozen.excluded++;}
            }

            try{
                java.util.List<CompareHand> ct=buildCasualTrace(comparisonEvidenceText(s.id),s.id);
                if(ct!=null&&!ct.isEmpty()&&isSourceVerifiedCasual(s.id)){casual.value+=ct.get(ct.size()-1).after-100.0;casual.supported++;}
                else if(ct!=null&&!ct.isEmpty())casual.excluded++;
            }catch(Exception ignored){}

            try{
                StatWatchView sw=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);
                if(sw!=null&&!sw.preambleStart&&!Double.isNaN(sw.chosenBank)){
                    // v15.10.295 evidence audit: source exhaustion is a boundary, not a final result.
                    // Keep the last supported bankroll available in the detailed evidence surfaces,
                    // but never promote it into cumulative running P/L.
                    if("SOURCE_EXHAUSTED".equals(sw.chosenTermination)) stat.excluded++;
                    else {double end=sw.exitHand>0&&!Double.isNaN(sw.exitBank)?sw.exitBank:sw.chosenBank;stat.value+=end-100.0;stat.supported++;}
                }
            }catch(Exception ignored){}

            try{
                UltimateAIView u=parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);
                if(u!=null&&!Double.isNaN(u.aiBank)){if(u.aiComplete){ai.value+=u.aiBank-100.0;ai.supported++;}else ai.excluded++;}
            }catch(Exception ignored){}
        }
        observed.note=observed.supported+" observed session"+(observed.supported==1?"":"s");
        frozen.note=runningSupportNote(frozen,"source-complete");
        casual.note=runningSupportNote(casual,"source-complete");
        stat.note=runningSupportNote(stat,"source-complete behavioural");
        ai.note=runningSupportNote(ai,"source-complete");

        JPanel outer=new JPanel(new BorderLayout(0,4));outer.setOpaque(false);outer.setAlignmentX(Component.LEFT_ALIGNMENT);outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,128));
        JLabel title=new JLabel("RUNNING PROFIT / LOSS — ALL PLAYERS",SwingConstants.CENTER);title.setOpaque(true);title.setBackground(new Color(5,74,155));title.setForeground(Color.WHITE);title.setFont(new Font("SansSerif",Font.BOLD,14));title.setBorder(new EmptyBorder(4,8,4,8));outer.add(title,BorderLayout.NORTH);
        JPanel cards=new JPanel(new GridLayout(1,5,6,0));cards.setOpaque(false);
        cards.add(runningPLCard("OBSERVED / LIVE",observed,new Color(20,128,72)));
        cards.add(runningPLCard("FROZEN REFERENCE",frozen,new Color(30,83,155)));
        cards.add(runningPLCard("FIXED-POLICY CASUAL",casual,new Color(30,83,155)));
        cards.add(runningPLCard("STAT-WATCHING CASUAL",stat,new Color(20,128,72)));
        cards.add(runningPLCard("ULTIMATE AI",ai,new Color(20,128,72)));
        outer.add(cards,BorderLayout.CENTER);return outer;
    }

    private String runningSupportNote(RunningPLTotal x,String basis){
        String n=x.supported+" "+basis+" session"+(x.supported==1?"":"s");
        if(x.excluded>0)n+=" • "+x.excluded+" source-exhausted omitted";
        return n;
    }

    private JComponent runningPLCard(String name,RunningPLTotal x,Color accent){
        JPanel p=new JPanel();p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));p.setBackground(Color.WHITE);p.setBorder(new CompoundBorder(new LineBorder(new Color(58,177,213),1),new EmptyBorder(6,5,5,5)));
        JLabel n=new JLabel(name,SwingConstants.CENTER);n.setFont(new Font("SansSerif",Font.BOLD,10));n.setForeground(NAVY);n.setAlignmentX(Component.CENTER_ALIGNMENT);
        String amount=x.supported>0?signedMoney(x.value):"N/A";
        JLabel v=new JLabel(amount,SwingConstants.CENTER);v.setFont(new Font("SansSerif",Font.BOLD,20));v.setForeground(x.supported>0?(x.value>=0?new Color(22,139,65):new Color(198,40,40)):new Color(30,83,155));v.setAlignmentX(Component.CENTER_ALIGNMENT);
        JTextArea note=new JTextArea(x.note);note.setFont(new Font("SansSerif",Font.BOLD,8));note.setForeground(x.excluded>0?new Color(190,48,48):new Color(76,91,105));note.setOpaque(false);note.setEditable(false);note.setFocusable(false);note.setLineWrap(true);note.setWrapStyleWord(true);note.setRows(2);note.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));note.setAlignmentX(Component.CENTER_ALIGNMENT);
        p.add(n);p.add(Box.createVerticalStrut(1));p.add(v);p.add(Box.createVerticalStrut(1));p.add(note);return p;
    }

    private JComponent summarySection(String heading, java.util.List<SessionSummaryRow> rows){
        JPanel p=new JPanel(new BorderLayout(0,3));p.setOpaque(false);p.setAlignmentX(Component.LEFT_ALIGNMENT);p.setMaximumSize(new Dimension(Integer.MAX_VALUE, Math.max(86,42+rows.size()*25)));
        JLabel h=new JLabel(heading);h.setFont(new Font("SansSerif",Font.BOLD,11));h.setForeground(new Color(54,72,88));p.add(h,BorderLayout.NORTH);
        String[] cols={"PLAYER / CONTROLLER","HANDS","FINAL BANKROLL","PEAK","TROUGH","EXPOSURE","AVG WAGER","<html><center>P/L SAME<br>START-HAND POINT</center></html>","<html><center>P/L DIFFERENT<br>START-HAND POINT</center></html>","TERMINATION / EVIDENCE"};
        Object[][] data=new Object[rows.size()][cols.length];for(int i=0;i<rows.size();i++)data[i]=rows.get(i).cells();
        JTable table=new JTable(data,cols){public boolean isCellEditable(int r,int c){return false;}};table.setFont(new Font("SansSerif",Font.PLAIN,10));table.setRowHeight(23);table.getTableHeader().setFont(new Font("SansSerif",Font.BOLD,9));table.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
        int[] widths={175,55,120,82,82,90,90,90,100,300};for(int i=0;i<widths.length;i++)table.getColumnModel().getColumn(i).setPreferredWidth(widths[i]);
        // v15.10.115: do not place each session table inside its own scroll pane.
        // The outer Session Summary scroll pane owns navigation so all player rows remain visible at a glance.
        JPanel tableBlock=new JPanel(new BorderLayout());tableBlock.setOpaque(false);
        JTableHeader th=table.getTableHeader();th.setReorderingAllowed(false);th.setPreferredSize(new Dimension(th.getPreferredSize().width,48));
        tableBlock.add(th,BorderLayout.NORTH);tableBlock.add(table,BorderLayout.CENTER);
        int fullH=th.getPreferredSize().height + rows.size()*table.getRowHeight() + 2;
        tableBlock.setPreferredSize(new Dimension(1200,fullH));
        tableBlock.setMaximumSize(new Dimension(Integer.MAX_VALUE,fullH));
        p.setMaximumSize(new Dimension(Integer.MAX_VALUE, Math.max(86,28+fullH)));
        p.add(tableBlock,BorderLayout.CENTER);return p;
    }

    private SessionSummaryRow metricsRow(String label, ReplayStoryMetrics m, double finalBank, boolean finalAvailable, double avgWager, String same, String diff, String term){
        SessionSummaryRow r=new SessionSummaryRow();r.player=label;r.hands=Integer.toString(m.hands);r.finalBank=finalAvailable?("£"+money(finalBank)):("N/A"+(Double.isNaN(finalBank)?"":" (last £"+money(finalBank)+")"));r.peak="£"+money(m.peak);r.trough="£"+money(m.trough);r.exposure="£"+money(m.exposure);r.avgWager=Double.isNaN(avgWager)?"N/A":"£"+money(avgWager);r.samePL=same;r.diffPL=diff;r.termination=term;return r;
    }
    private double observedAvgWager(Session s){if(s==null||s.hands.isEmpty())return Double.NaN;double z=0;for(Hand h:s.hands)z+=parseMoney(h.wager);return z/s.hands.size();}
    private double compareAvgWager(java.util.List<CompareHand> hs){if(hs==null||hs.isEmpty())return Double.NaN;double z=0;for(CompareHand h:hs)z+=h.wager;return z/hs.size();}
    private double pathPeak(java.util.List<Double> p){if(p==null||p.isEmpty())return Double.NaN;double z=-Double.MAX_VALUE;for(double x:p)z=Math.max(z,x);return z;}
    private double pathTrough(java.util.List<Double> p){if(p==null||p.isEmpty())return Double.NaN;double z=Double.MAX_VALUE;for(double x:p)z=Math.min(z,x);return z;}

    private String[] decisionPointPL(Session frozenObserved, java.util.List<CompareHand> frozenTrace, java.util.List<CompareHand> target, Session targetObserved){
        try{
            int n;if(targetObserved!=null){n=Math.min(frozenTrace.size(),targetObserved.hands.size());double same=0,diff=0;for(int i=0;i<n;i++){CompareHand f=frozenTrace.get(i);Hand o=targetObserved.hands.get(i);double pl=o.researchEnd()-o.researchStart();if(sameOpeningState(o,f))same+=pl;else diff+=pl;}return new String[]{signedMoney(same),signedMoney(diff)};}
            if(target!=null&&frozenObserved!=null){n=Math.min(frozenObserved.hands.size(),target.size());double same=0,diff=0;for(int i=0;i<n;i++){Hand f=frozenObserved.hands.get(i);CompareHand c=target.get(i);double pl=c.after-c.before;if(sameOpeningState(f,c))same+=pl;else diff+=pl;}return new String[]{signedMoney(same),signedMoney(diff)};}
            if(target!=null&&frozenTrace!=null){n=Math.min(frozenTrace.size(),target.size());double same=0,diff=0;for(int i=0;i<n;i++){CompareHand f=frozenTrace.get(i),c=target.get(i);double pl=c.after-c.before;if(sameOpeningStateCompare(f,c))same+=pl;else diff+=pl;}return new String[]{signedMoney(same),signedMoney(diff)};}
        }catch(Exception ignored){}
        return new String[]{"N/A","N/A"};
    }
    private static boolean sameOpeningStateCompare(CompareHand a,CompareHand b){
        if(a==null||b==null)return false;java.util.List<String> ap=new ArrayList<>(),bp=new ArrayList<>();if(a.player!=null&&a.player.size()>=2){ap.add(openingRank(a.player.get(0)));ap.add(openingRank(a.player.get(1)));}if(b.player!=null&&b.player.size()>=2){bp.add(openingRank(b.player.get(0)));bp.add(openingRank(b.player.get(1)));}Collections.sort(ap);Collections.sort(bp);return ap.size()==2&&ap.equals(bp)&&openingRank(a.dealerUp).equals(openingRank(b.dealerUp));
    }

    // v15.10.238: permanent semantic regression gate. These invariants protect the two
    // defects found during the Session 1-14 audit: terminal dealer over-consumption and
    // retrospective SAME/DIFFERENT reclassification from later cards.
    private static int terminalDealerCardLimit(boolean playerBust, boolean natural){return (playerBust||natural)?2:Integer.MAX_VALUE;}
    private static void replaySemanticRegressionGate(){
        CompareHand a=new CompareHand(), b=new CompareHand();
        a.player.addAll(List.of("A♦","Q♣","5♥")); a.dealerUp="4♣"; a.dealer.addAll(List.of("4♣","2♥","K♦"));
        b.player.addAll(List.of("Q♠","A♥")); b.dealerUp="4♦"; b.dealer.addAll(List.of("4♦","2♣"));
        if(!sameOpeningStateCompare(a,b))throw new IllegalStateException("Replay integrity gate I1 failed: later cards changed an identical opening state");
        b.player.set(1,"9♥");
        if(sameOpeningStateCompare(a,b))throw new IllegalStateException("Replay integrity gate I2 failed: changed opening player rank classified SAME");
        b.player.set(1,"A♥"); b.dealerUp="5♦";
        if(sameOpeningStateCompare(a,b))throw new IllegalStateException("Replay integrity gate I3 failed: changed dealer upcard classified SAME");
        if(terminalDealerCardLimit(true,false)!=2||terminalDealerCardLimit(false,true)!=2||terminalDealerCardLimit(false,false)!=Integer.MAX_VALUE)
            throw new IllegalStateException("Replay integrity gate I4 failed: terminal dealer settlement rule");
        if(isTwoCardNatural(List.of("J♣","7♠")) || !isTwoCardNatural(List.of("A♦","Q♠")) || !isTwoCardNatural(List.of("A♦","Q?")))
            throw new IllegalStateException("Replay integrity gate I5 failed: NATURAL must derive from two known ranks even when suit is unresolved");
        Hand h1=new Hand();h1.player.addAll(List.of("2♣","3♦","4♥","5♠","6♣"));h1.actions.addAll(List.of("HIT","STAND"));
        if(!"HIT → HIT → HIT → STAND".equals(completeObservedActionDisplay(h1)))throw new IllegalStateException("Replay integrity gate I6 failed: observed action trail truncation H1 pattern");
        Hand h8=new Hand();h8.player.addAll(List.of("5♣","6♦","7♥"));h8.actions.add("HIT");
        if(!"HIT → STAND".equals(completeObservedActionDisplay(h8)))throw new IllegalStateException("Replay integrity gate I6 failed: observed terminal STAND completion H8 pattern");
        Hand hb=new Hand();hb.player.addAll(List.of("10♣","6♦","8♥"));hb.actions.add("HIT");hb.playerBust=true;
        if(!"HIT".equals(completeObservedActionDisplay(hb)))throw new IllegalStateException("Replay integrity gate I6 failed: bust must not gain STAND");
    }

    private java.util.List<SessionSummaryRow> formalSummaryRows(Session s){
        java.util.List<SessionSummaryRow> rows=new ArrayList<>();boolean hybrid=isHybridSession(s);ReplayStoryMetrics om=observedMetrics(s,s.hands.size()-1);
        java.util.List<CompareHand> frozenTrace=null;try{if(hybrid)frozenTrace=buildFrozenTrace(comparisonEvidenceText(s.id),s.id);}catch(Exception ignored){}
        String[] opl=hybrid&&frozenTrace!=null?decisionPointPL(null,frozenTrace,null,s):new String[]{"REFERENCE","REFERENCE"};
        rows.add(metricsRow(hybrid?"Observed Hybrid":"Observed / Frozen",om,s.hands.get(s.hands.size()-1).researchEnd(),true,observedAvgWager(s),opl[0],opl[1],om.hands>=30?"COMPLETED H30":"OBSERVED END H"+om.hands));
        if(hybrid&&frozenTrace!=null&&!frozenTrace.isEmpty()){
            ReplayStoryMetrics fm=compareMetrics(frozenTrace,frozenTrace.size()-1);boolean complete=frozenTrace.size()>=30;String term=complete?"COMPLETED H30":"SOURCE_EXHAUSTED H"+frozenTrace.size();rows.add(metricsRow("Frozen Reference",fm,frozenTrace.get(frozenTrace.size()-1).after,complete,compareAvgWager(frozenTrace),"REFERENCE","REFERENCE",term));
        }
        try{
            java.util.List<CompareHand> casual=buildCasualTrace(comparisonEvidenceText(s.id),s.id);if(!casual.isEmpty()){
                ReplayStoryMetrics cm=compareMetrics(casual,casual.size()-1);boolean complete=isSourceVerifiedCasual(s.id);String[] pl=hybrid&&frozenTrace!=null?decisionPointPL(null,frozenTrace,casual,null):decisionPointPL(s,null,casual,null);String term=complete?(casual.size()>=30?"SOURCE-COMPLETE H30":"SOURCE-COMPLETE H"+casual.size()):"SOURCE_EXHAUSTED H"+casual.size();rows.add(metricsRow("Fixed-Policy Casual",cm,casual.get(casual.size()-1).after,complete,compareAvgWager(casual),pl[0],pl[1],term));
            }
        }catch(Exception ignored){}
        StatWatchView sw=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);if(sw!=null&&!sw.preambleStart&&!Double.isNaN(sw.chosenBank)){
            computeStatWatchWagerSummary(sw);SessionSummaryRow r=new SessionSummaryRow();r.player="Stat-Watching Casual";r.hands=Integer.toString(sw.chosenHands);boolean sourceComplete=!"SOURCE_EXHAUSTED".equals(sw.chosenTermination);r.finalBank=sourceComplete?("£"+money(sw.chosenBank)):("N/A (last £"+money(sw.chosenBank)+")");double pk=pathPeak(sw.chosenPath),tr=pathTrough(sw.chosenPath);r.peak=Double.isNaN(pk)?"N/A":"£"+money(pk);r.trough=Double.isNaN(tr)?"N/A":"£"+money(tr);r.exposure=Double.isNaN(sw.chosenExposure)?"N/A":"£"+money(sw.chosenExposure);r.avgWager=Double.isNaN(sw.meanInitialWager)?"N/A":"£"+money(sw.meanInitialWager);r.samePL="N/A";r.diffPL="N/A";r.termination=sw.chosenTermination+(sw.exitHand>0?" H"+sw.exitHand:"");rows.add(r);
        }
        UltimateAIView ai=parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);if(ai!=null&&!Double.isNaN(ai.aiBank)){
            SessionSummaryRow r=new SessionSummaryRow();r.player="Ultimate AI";r.hands=Integer.toString(ai.hands);r.finalBank=ai.aiComplete?"£"+money(ai.aiBank):"N/A (last £"+money(ai.aiBank)+")";double pk=pathPeak(ai.path),tr=pathTrough(ai.path);r.peak=Double.isNaN(pk)?"N/A":"£"+money(pk);r.trough=Double.isNaN(tr)?"N/A":"£"+money(tr);r.exposure=Double.isNaN(ai.exposure)?"N/A":"£"+money(ai.exposure);r.avgWager="N/A";if(ai.actionOverrides==0&&!ai.path.isEmpty()){double apl=ai.path.get(ai.path.size()-1)-ai.path.get(0);r.samePL=signedMoney(apl);r.diffPL=signedMoney(0);}else{r.samePL="N/A";r.diffPL="N/A";}r.termination=ai.termination+(ai.aiComplete?" / SOURCE-COMPLETE":" / NOT SOURCE-COMPLETE");rows.add(r);
        }
        return rows;
    }

    private java.util.List<SessionSummaryRow> preambleSummaryRows(Session s){
        java.util.List<SessionSummaryRow> rows=new ArrayList<>();if(!(s.hadPreamble||chartPreambleHands(s)>0))return rows;String text=loadedOutputText==null?"":loadedOutputText;String sid=Pattern.quote(s.id);
        Pattern fp=Pattern.compile("(?s)PREAMBLE-START COUNTERFACTUAL \\| SESSION "+sid+".*?Frozen from preamble start: hands (\\d+) \\| final £([0-9.]+)(.*?)(?=Casual from preamble start:|END PREAMBLE-START|$)",Pattern.CASE_INSENSITIVE);Matcher fm=fp.matcher(text);if(fm.find()){SessionSummaryRow r=new SessionSummaryRow();r.player="Frozen Reference";r.hands=fm.group(1);r.finalBank="£"+fm.group(2);r.termination=fm.group(3).toUpperCase(Locale.ROOT).contains("SOURCE_EXHAUSTED")?"SOURCE_EXHAUSTED":"SOURCE-SUPPORTED PREAMBLE REPLAY";rows.add(r);}
        Pattern cp=Pattern.compile("(?s)PREAMBLE-START COUNTERFACTUAL \\| SESSION "+sid+".*?Casual from preamble start: hands (\\d+) \\| final £([0-9.]+)(.*?)(?=END PREAMBLE-START|$)",Pattern.CASE_INSENSITIVE);Matcher cm=cp.matcher(text);if(cm.find()){SessionSummaryRow r=new SessionSummaryRow();r.player="Fixed-Policy Casual";r.hands=cm.group(1);r.finalBank="£"+cm.group(2);r.termination=cm.group(3).toUpperCase(Locale.ROOT).contains("SOURCE_EXHAUSTED")?"SOURCE_EXHAUSTED":"SOURCE-SUPPORTED PREAMBLE REPLAY";rows.add(r);}
        StatWatchView sw=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);if(sw!=null&&sw.preambleStart&&!Double.isNaN(sw.chosenBank)){computeStatWatchWagerSummary(sw);SessionSummaryRow r=new SessionSummaryRow();r.player="Stat-Watching Casual";r.hands=Integer.toString(sw.chosenHands);boolean sourceComplete=!"SOURCE_EXHAUSTED".equals(sw.chosenTermination);r.finalBank=sourceComplete?("£"+money(sw.chosenBank)):("N/A (last £"+money(sw.chosenBank)+")");double pk=pathPeak(sw.chosenPath),tr=pathTrough(sw.chosenPath);r.peak=Double.isNaN(pk)?"N/A":"£"+money(pk);r.trough=Double.isNaN(tr)?"N/A":"£"+money(tr);r.exposure=Double.isNaN(sw.chosenExposure)?"N/A":"£"+money(sw.chosenExposure);r.avgWager=Double.isNaN(sw.meanInitialWager)?"N/A":"£"+money(sw.meanInitialWager);r.termination=sw.chosenTermination+(sw.exitHand>0?" H"+sw.exitHand:"");rows.add(r);}
        return rows;
    }

    private JComponent buildFascinatingStatsCorner() {
        java.util.List<Session> statsJourneys = observationalSessions();
        for(Session q:replayCatalog){
            if(q!=null && q.standalonePreamble && !q.hands.isEmpty() && statsJourneys.stream().noneMatch(x->x!=null&&Objects.equals(x.id,q.id))) statsJourneys.add(q);
        }
        FascinatingStats stats = fascinatingStats(statsJourneys);
        JPanel outer = new JPanel(new BorderLayout(0, 10));
        outer.setBackground(Color.WHITE);
        outer.setBorder(new CompoundBorder(
                new LineBorder(new Color(208, 218, 226)),
                new EmptyBorder(14, 14, 14, 14)));

        JPanel titleBlock = new JPanel();
        titleBlock.setOpaque(false);
        titleBlock.setLayout(new BoxLayout(titleBlock, BoxLayout.Y_AXIS));
        JLabel title = new JLabel("FASCINATING STATS CORNER");
        title.setFont(new Font("SansSerif", Font.BOLD, 18));
        title.setForeground(NAVY);
        title.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel sub = new JLabel("Observed-player curiosities • sessions + accounted standalone preambles • descriptive only");
        sub.setFont(new Font("SansSerif", Font.PLAIN, 13));
        sub.setForeground(new Color(92, 103, 113));
        sub.setAlignmentX(Component.LEFT_ALIGNMENT);
        titleBlock.add(title); titleBlock.add(Box.createVerticalStrut(2)); titleBlock.add(sub);
        outer.add(titleBlock, BorderLayout.NORTH);

        JPanel content = new ViewportWidthPanel();
        content.setOpaque(false);
        content.setBorder(new EmptyBorder(0,0,0,12));
        content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS));

        JPanel cards = new JPanel(new GridLayout(1, 2, 9, 0));
        cards.setOpaque(false);
        cards.add(fascinatingMetricCard("DEALER 10 + 10", String.valueOf(stats.dealerTenTen),
                "first two dealer cards"));
        cards.add(fascinatingMetricCard("PLAYER 10-CARD BUSTS", String.valueOf(stats.totalPlayerTenBusts),
                "10/J/Q/K caused bust"));
        cards.setMaximumSize(new Dimension(Integer.MAX_VALUE, 84));
        content.add(cards);
        content.add(Box.createVerticalStrut(9));

        // v15.10.344-UAT: obsolete latest-observed-preamble headline removed from Stats Corner.
        // Standalone preamble evidence remains retained and replayable; only this duplicate presentation line is removed.

        // v15.10.344-UAT: one centred sentence prevents the two former labels from
        // visually colliding at narrower Windows/DPI widths; extra top spacing separates
        // the sentence from the metric cards above.
        content.add(Box.createVerticalStrut(5));
        JPanel positive = new JPanel(new BorderLayout());
        positive.setBackground(new Color(247, 249, 251));
        positive.setBorder(new CompoundBorder(new LineBorder(new Color(224, 230, 235)), new EmptyBorder(10, 12, 10, 12)));
        JLabel ph = new JLabel("PLAYER 10-CARD BUSTS OCCURRED IN " + stats.sessionsWithTenBust +
                " OBSERVED JOURNEYS  •  " + stats.positiveWithTenBust + " OF THOSE JOURNEYS FINISHED IN PROFIT", SwingConstants.CENTER);
        ph.setFont(new Font("SansSerif", Font.BOLD, 12));
        ph.setForeground(NAVY);
        positive.add(ph, BorderLayout.CENTER);
        positive.setMaximumSize(new Dimension(Integer.MAX_VALUE, 46));
        content.add(positive);
        content.add(Box.createVerticalStrut(12));

        // v15.10.46: rolling research-news panel.  This is deliberately editorial/descriptive:
        // it interrogates currently loaded evidence, may replace older curiosities as new sessions
        // arrive, and never changes Frozen/Casual/Stat-Watching behaviour or formal metrics.
        content.add(buildResearchNewsPanel(stats));
        content.add(Box.createVerticalStrut(12));

        JLabel chartTitle = new JLabel("PLAYER 10-CARD BUSTS BY SESSION");
        chartTitle.setFont(new Font("SansSerif", Font.BOLD, 14));
        chartTitle.setForeground(new Color(45, 57, 68));
        chartTitle.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(chartTitle);
        JLabel chartSub = new JLabel("Bar height = bust count • P/L shown below each bar");
        chartSub.setFont(new Font("SansSerif", Font.PLAIN, 12));
        chartSub.setForeground(new Color(100, 108, 116));
        chartSub.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(chartSub);
        content.add(Box.createVerticalStrut(4));

        // v15.10.60: preserve the accepted eight-session chart density.  Session 9 starts
        // a second full-size band rather than squeezing more bars into the original graph.
        final int CORE_SESSIONS_PER_CHART=8;
        for(int pageStart=0,pageNo=1;pageStart<stats.points.size();pageStart+=CORE_SESSIONS_PER_CHART,pageNo++){
            int pageEnd=Math.min(stats.points.size(),pageStart+CORE_SESSIONS_PER_CHART);
            java.util.List<TenBustPoint> pagePoints=new ArrayList<>(stats.points.subList(pageStart,pageEnd));
            if(pageNo>1){JLabel ct=new JLabel("CONTINUED — "+pagePoints.get(0).label+" TO "+pagePoints.get(pagePoints.size()-1).label);ct.setFont(new Font("SansSerif",Font.BOLD,10));ct.setForeground(NAVY);ct.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(ct);content.add(Box.createVerticalStrut(3));}
            TenBustBarPanel chart = new TenBustBarPanel(pagePoints);
            chart.setPreferredSize(new Dimension(430, 280)); chart.setMinimumSize(new Dimension(330, 220));
            chart.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE)); chart.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(chart);
            content.add(Box.createVerticalStrut(pageEnd<stats.points.size()?10:12));
        }

        // v15.10.114: the fixed-policy Casual-only 10-card-bust graph was retired.
        // The observed-player 10-card-bust graph above remains as a useful curiosity.

        content.add(Box.createVerticalStrut(30));
        JLabel swTitle = new JLabel("STAT-WATCHING CASUAL — EXPLORATORY");
        swTitle.setFont(new Font("SansSerif", Font.BOLD, 14));
        swTitle.setForeground(new Color(45, 57, 68));
        swTitle.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(swTitle);
        JLabel swSub = new JLabel("<html><div style='width:270px'>Deterministic side experiment • reacts only to evidence already visible to the player • Frozen and established Casual remain unchanged</div></html>");
        swSub.setFont(new Font("SansSerif", Font.PLAIN, 12));
        swSub.setForeground(new Color(100,108,116)); swSub.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(swSub); content.add(Box.createVerticalStrut(5));
        if(!stats.statWatchPoints.isEmpty()){
            JLabel swChartTitle=new JLabel("FINAL BANKROLL — CHOSEN VS IF STAYED");
            swChartTitle.setFont(new Font("SansSerif",Font.BOLD,11)); swChartTitle.setForeground(NAVY); swChartTitle.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(swChartTitle);
            JLabel swChartLegend=new JLabel("solid = chosen final bankroll  •  outline = same personality with walk-away suppressed");
            swChartLegend.setFont(new Font("SansSerif",Font.PLAIN,9)); swChartLegend.setForeground(new Color(92,102,110)); swChartLegend.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(swChartLegend);
            content.add(Box.createVerticalStrut(3));
            // v15.10.38: preserve the accepted five-session density. Additional eligible sessions
            // create another full-size chart beneath the previous one inside the existing scroll pane.
            // Running-profit summaries remain rolling/cumulative through the last session on each chart.
            final int STAT_WATCH_SESSIONS_PER_CHART=5;
            for(int pageStart=0,pageNo=1;pageStart<stats.statWatchPoints.size();pageStart+=STAT_WATCH_SESSIONS_PER_CHART,pageNo++){
                int pageEnd=Math.min(stats.statWatchPoints.size(),pageStart+STAT_WATCH_SESSIONS_PER_CHART);
                java.util.List<StatWatchView> pagePoints=new ArrayList<>(stats.statWatchPoints.subList(pageStart,pageEnd));
                java.util.List<StatWatchView> cumulativePoints=new ArrayList<>(stats.statWatchPoints.subList(0,pageEnd));
                if(pageNo>1){
                    String continuedTitle = pagePoints.size()==1
                        ? "CONTINUED — SESSION "+pagePoints.get(0).label
                        : "CONTINUED — SESSIONS "+pagePoints.get(0).label+" TO "+pagePoints.get(pagePoints.size()-1).label;
                    JLabel pageTitle=new JLabel(continuedTitle);
                    pageTitle.setFont(new Font("SansSerif",Font.BOLD,10)); pageTitle.setForeground(NAVY); pageTitle.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(pageTitle);
                    content.add(Box.createVerticalStrut(3));
                }
                StatWatchBarPanel swp=new StatWatchBarPanel(pagePoints,cumulativePoints);
                swp.setPreferredSize(new Dimension(700,430)); swp.setMinimumSize(new Dimension(560,400));
                swp.setMaximumSize(new Dimension(Integer.MAX_VALUE,490)); swp.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(swp);
                content.add(Box.createVerticalStrut(pageEnd<stats.statWatchPoints.size()?12:8));
            }
            JLabel stories=new JLabel("SESSION STORIES — WHAT CAUGHT HIS EYE"); stories.setFont(new Font("SansSerif",Font.BOLD,10)); stories.setForeground(NAVY); stories.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(stories);
            JLabel storiesSub=new JLabel("<html><div style='width:255px'>A simple account of what the Stat-Watching player noticed, what he did, and what happened.</div></html>"); storiesSub.setFont(new Font("SansSerif",Font.PLAIN,9)); storiesSub.setForeground(new Color(96,105,113)); storiesSub.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(storiesSub);
            content.add(Box.createVerticalStrut(5));
            for(StatWatchView sv:stats.statWatchPoints){content.add(statWatchStoryCard(sv));content.add(Box.createVerticalStrut(6));}
            if(stats.latestStatWatch!=null){
                content.add(Box.createVerticalStrut(2));
                JLabel why=new JLabel("EXAMPLES OF WHAT INFLUENCED HIM — LATEST AVAILABLE SESSION"); why.setFont(new Font("SansSerif",Font.BOLD,10)); why.setForeground(NAVY); why.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(why);
                java.util.List<String> inf=stats.latestStatWatch.influences;
                if(inf.isEmpty()){JLabel z=new JLabel("No unusual wager, hunch or walk-away trigger fired in this session.");z.setFont(new Font("SansSerif",Font.PLAIN,10));z.setForeground(new Color(95,104,112));z.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(z);}
                else for(int i=0;i<Math.min(6,inf.size());i++){JLabel q=new JLabel("<html><div style='width:285px'>• "+html(inf.get(i))+"</div></html>");q.setFont(new Font("SansSerif",Font.PLAIN,10));q.setForeground(new Color(76,87,97));q.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(q);}
            }
        }else{
            JLabel na=new JLabel("No S-generated Stat-Watching Casual evidence is available yet."); na.setFont(new Font("SansSerif",Font.PLAIN,10));na.setForeground(new Color(105,112,119));na.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(na);
        }
        content.add(Box.createVerticalStrut(10));

        // Ultimate AI remains evidence-led: retain the compact outcome graph, then show only a concise latest-session decision story from source-recorded AI overrides.
        if(!stats.ultimatePoints.isEmpty()){
            content.add(Box.createVerticalStrut(30));
            JLabel aiTitle=new JLabel("ULTIMATE AI — FROZEN-WEIGHTED SYNTHESIS"); aiTitle.setFont(new Font("SansSerif",Font.BOLD,14));aiTitle.setForeground(new Color(45,57,68));aiTitle.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(aiTitle);
            JLabel aiSub=new JLabel("<html><div style='width:270px'>Silent deterministic layer • solid = completed Ultimate AI • dashed AI = source-exhausted / last-supported • outline = same-basis Frozen • cumulative P/L uses completed results only</div></html>");aiSub.setFont(new Font("SansSerif",Font.PLAIN,10));aiSub.setForeground(new Color(100,108,116));aiSub.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(aiSub);content.add(Box.createVerticalStrut(5));
            final int AI_SESSIONS_PER_CHART=5;
            for(int pageStart=0,pageNo=1;pageStart<stats.ultimatePoints.size();pageStart+=AI_SESSIONS_PER_CHART,pageNo++){
                int pageEnd=Math.min(stats.ultimatePoints.size(),pageStart+AI_SESSIONS_PER_CHART);java.util.List<UltimateAIView> page=new ArrayList<>(stats.ultimatePoints.subList(pageStart,pageEnd));java.util.List<UltimateAIView> cumulative=new ArrayList<>(stats.ultimatePoints.subList(0,pageEnd));
                if(pageNo>1){JLabel pt=new JLabel("CONTINUED — "+page.get(0).label+" TO "+page.get(page.size()-1).label);pt.setFont(new Font("SansSerif",Font.BOLD,10));pt.setForeground(NAVY);pt.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(pt);content.add(Box.createVerticalStrut(3));}
                UltimateAIBarPanel ap=new UltimateAIBarPanel(page,cumulative);ap.setPreferredSize(new Dimension(470,430));ap.setMinimumSize(new Dimension(360,400));ap.setMaximumSize(new Dimension(Integer.MAX_VALUE,500));ap.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(ap);content.add(Box.createVerticalStrut(pageEnd<stats.ultimatePoints.size()?12:8));
            }
            UltimateAIView latest=stats.ultimatePoints.get(stats.ultimatePoints.size()-1);
            if(latest.aiComplete&&!Double.isNaN(latest.aiBank)){
                String wlp=(latest.wins>=0)?(" • W/L/P "+latest.wins+"/"+latest.losses+"/"+latest.pushes):"";
                String term=(latest.termination==null||latest.termination.isBlank())?"":(" • "+latest.termination);
                double pl=latest.aiBank-100.0;
                JLabel aiSummary=new JLabel("<html><div style='width:285px'><b>LATEST SOURCE-SUPPORTED RESULT — "+html(latest.label)+"</b><br>Final £"+String.format(Locale.ROOT,"%.2f",latest.aiBank)+" • P/L "+(pl>=0?"+":"-")+"£"+String.format(Locale.ROOT,"%.2f",Math.abs(pl))+" • "+latest.hands+" hands"+wlp+" • Exposure £"+String.format(Locale.ROOT,"%.2f",latest.exposure)+term+" • SOURCE-COMPLETE<br><span style='font-size:9px'>Overrides: action "+latest.actionOverrides+" • wager "+latest.wagerOverrides+" • exit "+latest.exitOverrides+"</span></div></html>");
                aiSummary.setFont(new Font("SansSerif",Font.PLAIN,10));aiSummary.setForeground(new Color(45,57,68));aiSummary.setBorder(new CompoundBorder(new LineBorder(new Color(210,220,228)),new EmptyBorder(7,8,7,8)));aiSummary.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(aiSummary);content.add(Box.createVerticalStrut(8));
            }
            if(!latest.overrideEvents.isEmpty() || latest.repositoryThrough>=0){
                JLabel storyTitle=new JLabel("AI DECISION STORY — LATEST AVAILABLE SESSION");storyTitle.setFont(new Font("SansSerif",Font.BOLD,10));storyTitle.setForeground(NAVY);storyTitle.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(storyTitle);
                JLabel storySub=new JLabel("<html><div style='width:285px'>What the deterministic controller actually changed — source-recorded overrides only.</div></html>");storySub.setFont(new Font("SansSerif",Font.PLAIN,9));storySub.setForeground(new Color(96,105,113));storySub.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(storySub);content.add(Box.createVerticalStrut(4));
                if(latest.overrideEvents.isEmpty()){JLabel none=new JLabel("No action, wager or exit override fired in this session.");none.setFont(new Font("SansSerif",Font.PLAIN,10));none.setForeground(new Color(76,87,97));none.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(none);}
                else for(String ev:latest.overrideEvents){JLabel q=new JLabel("<html><div style='width:285px'>• "+html(ev)+"</div></html>");q.setFont(new Font("SansSerif",Font.PLAIN,10));q.setForeground(new Color(76,87,97));q.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(q);}
                JLabel counts=new JLabel("<html><div style='width:285px'><b>Overrides:</b> action "+latest.actionOverrides+" • wager "+latest.wagerOverrides+" • exit "+latest.exitOverrides+"</div></html>");counts.setFont(new Font("SansSerif",Font.PLAIN,10));counts.setForeground(new Color(76,87,97));counts.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(counts);
                if(latest.repositoryThrough>=0){JLabel tb=new JLabel("<html><div style='width:285px'><span style='font-size:9px'>Temporal boundary: repository evidence stopped at Session "+latest.repositoryThrough+"; "+html(latest.label)+" completed outcomes were excluded from decision knowledge.</span></div></html>");tb.setFont(new Font("SansSerif",Font.PLAIN,9));tb.setForeground(new Color(96,105,113));tb.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(tb);}
                content.add(Box.createVerticalStrut(8));
            }
        }

        JLabel foot = new JLabel("<html><div style='width:270px'>Positive session = final research bankroll above £100.<br>Dealer 10 + 10 includes any two 10-value cards (10/J/Q/K). Casual TRACE N/A/N/A is never treated as zero.</div></html>");
        foot.setFont(new Font("SansSerif", Font.PLAIN, 10));
        foot.setForeground(new Color(95, 104, 112));
        foot.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(foot);

        // v13.2: the Casual chart remains vertically scrollable, while its body now tracks
        // the viewport width so the rightmost sessions and explanatory text cannot be clipped.
        // Keep the heading fixed and the statistics body independently scrollable.
        JScrollPane statsScroll = new JScrollPane(content);
        statsScroll.setBorder(BorderFactory.createEmptyBorder());
        statsScroll.getViewport().setBackground(Color.WHITE);
        statsScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        statsScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED);
        statsScroll.getVerticalScrollBar().setUnitIncrement(18);
        outer.add(statsScroll, BorderLayout.CENTER);
        return outer;
    }

    private JComponent statWatchStoryCard(StatWatchView v) {
        JPanel p=new JPanel(); p.setOpaque(true); p.setBackground(new Color(248,250,252));
        p.setBorder(new CompoundBorder(new LineBorder(new Color(222,228,233)),new EmptyBorder(8,9,8,9)));
        p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS)); p.setAlignmentX(Component.LEFT_ALIGNMENT); p.setMaximumSize(new Dimension(Integer.MAX_VALUE,900));
        String sessionName=v.label!=null&&v.label.matches("S\\d+")?"SESSION "+v.label.substring(1):v.label;
        boolean chosenSourceComplete=!"SOURCE_EXHAUSTED".equals(v.chosenTermination);
        double chosenProfit=v.chosenBank-100.0;
        String profitText=Double.isNaN(v.chosenBank)?"":chosenSourceComplete
                ?String.format(Locale.ROOT," (PROFIT: %s£%.2f)",chosenProfit>0.004?"+":chosenProfit<-0.004?"-":"",Math.abs(chosenProfit))
                :String.format(Locale.ROOT," (FINAL: N/A • LAST SUPPORTED £%.2f)",v.chosenBank);
        String profitColour=!chosenSourceComplete?"#6b7280":chosenProfit>0.004?"#1769aa":chosenProfit<-0.004?"#c62828":"#304152";
        JLabel h=new JLabel("<html>STORY OF "+html(sessionName)+" — STAT-WATCHING PLAYER <span style='color:"+profitColour+"'>"+html(profitText)+"</span></html>"); h.setFont(new Font("SansSerif",Font.BOLD,10)); h.setForeground(new Color(48,65,82)); h.setAlignmentX(Component.LEFT_ALIGNMENT); p.add(h); p.add(Box.createVerticalStrut(3));
        for(String line:statWatchStoryLines(v)){JLabel q=new JLabel("<html><div style='width:255px'>"+storyHtml(line)+"</div></html>");q.setFont(new Font("SansSerif",Font.PLAIN,10));q.setForeground(new Color(78,88,98));q.setAlignmentX(Component.LEFT_ALIGNMENT);p.add(q);p.add(Box.createVerticalStrut(2));}
        return p;
    }

    private static java.util.List<String> statWatchStoryLines(StatWatchView v){
        java.util.List<String> out=new ArrayList<>();
        out.add(v.preambleStart?"He entered with the observed preamble already part of his visible table experience, so his impressions began before formal Hand 1.":"He entered at the formal-session start and built his impressions only from completed hands he had already seen.");
        out.add("Markers: ✓ = worked out well on this hand/path; ✗ = worked out badly; — = neutral or forced.");
        java.util.List<String> sparks=new ArrayList<>();
        for(String x:v.influences){if(x.contains("— EXIT:"))continue;String y=x.replaceFirst("^Hand \\d+ — ","");sparks.add(y);if(sparks.size()>=2)break;}
        if(sparks.isEmpty())out.add("What caught his eye: nothing unusual was strong enough to trigger a bigger wager or a hunch. He mainly followed the recent table pattern he had seen.");
        else out.add("What caught his eye: "+String.join("  •  ",sparks));
        if(!Double.isNaN(v.meanInitialWager)&&!Double.isNaN(v.maxInitialWager))
            out.add("Wagers: average £"+String.format(Locale.ROOT,"%.2f",v.meanInitialWager)+" • highest £"+String.format(Locale.ROOT,"%.2f",v.maxInitialWager)+".");

        // Audit each evidence-triggered wager press against the realised bankroll movement on that hand.
        for(String x:v.influences){
            Matcher wm=Pattern.compile("Hand (\\d+) — WAGER: (\\d+)x wager \\(£([0-9.]+)\\) • (.+)").matcher(x);
            if(!wm.find())continue;
            int h=Integer.parseInt(wm.group(1)); int mult=Integer.parseInt(wm.group(2)); double wager=Double.parseDouble(wm.group(3));
            String signal=wm.group(4);
            if(h<=0||h>=v.chosenPath.size())continue;
            double before=v.chosenPath.get(h-1), after=v.chosenPath.get(h), delta=after-before;
            String marker=outcomeMarker(delta);
            String verdict;
            if(delta>0.004) verdict="The hand went his way: bankroll rose by £"+String.format(Locale.ROOT,"%.2f",delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else if(delta<-0.004) verdict="The hand went against him: bankroll fell by £"+String.format(Locale.ROOT,"%.2f",-delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else verdict="The hand was neutral: bankroll stayed at £"+String.format(Locale.ROOT,"%.2f",after)+".";
            out.add(marker+" Wager outcome: Hand "+h+": what he had seen ("+signal+") made him press to "+mult+"x / £"+String.format(Locale.ROOT,"%.2f",wager)+". "+verdict);
        }

        // Hunches change the play action.  The retained evidence supports the realised hand result,
        // but not a separate alternate-action replay, so the marker describes only what happened after the hunch.
        for(String x:v.influences){
            Matcher hm=Pattern.compile("Hand (\\d+) — HUNCH: (.+)").matcher(x);
            if(!hm.find())continue;
            int h=Integer.parseInt(hm.group(1)); String detail=hm.group(2);
            if(h<=0||h>=v.chosenPath.size())continue;
            double before=v.chosenPath.get(h-1), after=v.chosenPath.get(h), delta=after-before;
            String marker=outcomeMarker(delta);
            String verdict;
            if(delta>0.004) verdict="That hand went his way, with bankroll rising by £"+String.format(Locale.ROOT,"%.2f",delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else if(delta<-0.004) verdict="That hand went against him, with bankroll falling by £"+String.format(Locale.ROOT,"%.2f",-delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else verdict="That hand was neutral, with bankroll unchanged at £"+String.format(Locale.ROOT,"%.2f",after)+".";
            out.add(marker+" Hunch outcome: Hand "+h+": "+detail+". "+verdict+"");
        }

        if(v.label.equalsIgnoreCase("Session 3"))
            out.add("Nothing tempted him to raise above £15. He reached £167.50 using ordinary wagers. If he had kept playing instead of walking, the same Stat-Watching style reached £265.00 after 30 hands, but only after another 22 hands of play.");
        if(v.label.equalsIgnoreCase("Session 4"))
            out.add("What caught his eye: the recent table looked low-card heavy (48%) and 10-value light (16%). That was enough to tempt a £30 wager despite a recent 0/2 record. The £30 hand then lost.");
        if(v.label.equalsIgnoreCase("Session 5")&&v.chosenPath.size()>4)
            out.add("An earlier walk would have been better here. His bankroll had already reached £152.50 after Hand 4. He eventually walked on Hand 11 with £92.50. Staying to the end would have finished at £62.50.");

        boolean forced=isForcedBankrollExit(v);
        if(forced){
            out.add("— Forced bankroll exit: Hand "+v.exitHand+": bankroll was £"+String.format(Locale.ROOT,"%.2f",v.exitBank)+", below the £15 table minimum. He could not place another £15 table-minimum wager, so this was forced rather than a walk-away choice.");
            out.add("If he stayed: the replay also ends at £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+" on Hand "+v.stayHands+" at the same bankroll boundary. Difference = £0.00.");
        } else if(v.exitHand>0){
            String marker="—";
            if(v.staySourceComplete&&!Double.isNaN(v.stayBank)) marker=outcomeMarker(v.exitBank-v.stayBank);
            out.add(marker+" Exit outcome: Hand "+v.exitHand+": he walked with £"+String.format(Locale.ROOT,"%.2f",v.exitBank)+" because the recent table and bankroll movement were enough to make him stop.");
            String base="If he ignored that walk-away choice and kept the same Stat-Watching style, he would reach £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+" after "+v.stayHands+" hands";
            if(v.staySourceComplete){double d=v.stayBank-v.exitBank;base+=d>0.004?" ( £"+String.format(Locale.ROOT,"%.2f",d)+" more than his exit )":d<-0.004?" ( £"+String.format(Locale.ROOT,"%.2f",-d)+" less than his exit )":" (the same bankroll as his exit)";base+=".";}
            else base+=" before the recorded cards ran out.";
            out.add(base);
            if(v.staySourceComplete && !v.stayPath.isEmpty() && v.exitHand>=0 && v.exitHand < v.stayPath.size()-1){
                double postExitTrough=Double.POSITIVE_INFINITY, postExitPeak=Double.NEGATIVE_INFINITY;
                for(int i=v.exitHand+1;i<v.stayPath.size();i++){
                    double b=v.stayPath.get(i);
                    postExitTrough=Math.min(postExitTrough,b);
                    postExitPeak=Math.max(postExitPeak,b);
                }
                // Path context is deliberately conditional.  If staying ultimately did better,
                // report only a later dip BELOW the secured exit bankroll.  If staying ultimately
                // did worse, report only a later rise ABOVE it.  The final-result ✓/✗ is unchanged.
                if(v.stayBank > v.exitBank+0.004 && Double.isFinite(postExitTrough) && postExitTrough < v.exitBank-0.004){
                    double below=v.exitBank-postExitTrough;
                    out.add("Along the way, however, his bankroll fell to £"+String.format(Locale.ROOT,"%.2f",postExitTrough)+" — £"+String.format(Locale.ROOT,"%.2f",below)+" below the amount he had already secured by walking — before eventually recovering to £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+".");
                } else if(v.stayBank < v.exitBank-0.004 && Double.isFinite(postExitPeak) && postExitPeak > v.exitBank+0.004){
                    out.add("Along the way, however, his bankroll recovered as high as £"+String.format(Locale.ROOT,"%.2f",postExitPeak)+" before eventually falling back to £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+".");
                }
            }
        } else {
            out.add("Why he stopped: he never chose an early walk-away. The recorded path ended at "+friendlyTermination(v.chosenTermination)+" after "+v.chosenHands+" hands.");
            if(!Double.isNaN(v.stayBank)) out.add("The stay replay keeps the same Stat-Watching style and simply removes the walk-away choice.");
        }
        return out;
    }

    private static String observedJourneyRef(Session s){
        if(s==null)return "Observed journey";
        if(s.standalonePreamble)return "Standalone Preamble "+s.id;
        return "Session "+s.publicationNumber;
    }

    private static String outcomeMarker(double delta){return delta>0.004?"✓":delta<-0.004?"✗":"—";}
    private static boolean isForcedBankrollExit(StatWatchView v){
        return v!=null && v.exitHand>0 && !Double.isNaN(v.exitBank) && v.exitBank<15.0-0.004
            && "BANKROLL".equals(v.stayTermination) && v.stayHands==v.exitHand
            && !Double.isNaN(v.stayBank) && Math.abs(v.stayBank-v.exitBank)<0.005;
    }

    private static String friendlyTermination(String x){if(x==null||x.isBlank())return "the recorded boundary";if(x.equals("COMPLETED"))return "the 30-hand boundary";if(x.equals("BANKROLL"))return "the bankroll/table-minimum boundary";if(x.equals("SOURCE_EXHAUSTED"))return "captured-cardstream exhaustion";if(x.equals("BEHAVIOURAL_EXIT"))return "his behavioural walk-away rule";return x.toLowerCase(Locale.ROOT).replace('_',' ');}

    // Tracks the viewport width so long labels cannot silently make the chart wider than
    // the visible Stats Corner.  Vertical scrolling remains available; horizontal clipping
    // is therefore removed rather than hidden behind a disabled horizontal scrollbar.
    private static class ViewportWidthPanel extends JPanel implements Scrollable {
        @Override public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); }
        @Override public int getScrollableUnitIncrement(Rectangle visibleRect,int orientation,int direction){ return 18; }
        @Override public int getScrollableBlockIncrement(Rectangle visibleRect,int orientation,int direction){ return Math.max(36, visibleRect.height-36); }
        @Override public boolean getScrollableTracksViewportWidth() { return true; }
        @Override public boolean getScrollableTracksViewportHeight() { return false; }
    }

    private JComponent buildResearchNewsPanel(FascinatingStats stats) {
        JPanel wrap=new JPanel(new BorderLayout(0,6)); wrap.setOpaque(true); wrap.setBackground(Color.WHITE);
        wrap.setBorder(new CompoundBorder(new LineBorder(new Color(218,225,232)),new EmptyBorder(10,12,11,12)));
        JLabel h=new JLabel("OBSERVATIONAL CURIOSITY — RETROSPECTIVE ONLY");
        h.setFont(new Font("SansSerif",Font.BOLD,14)); h.setForeground(NAVY); wrap.add(h,BorderLayout.NORTH);

        JPanel body=new JPanel(); body.setOpaque(false); body.setLayout(new BoxLayout(body,BoxLayout.Y_AXIS));
        JLabel marker=new JLabel("◆"); marker.setFont(new Font("SansSerif",Font.BOLD,20)); marker.setForeground(new Color(112,72,155)); marker.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel headline=new JLabel(" "); headline.setFont(new Font("SansSerif",Font.BOLD,16)); headline.setForeground(new Color(45,57,68)); headline.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel detail=new JLabel(" "); detail.setFont(new Font("SansSerif",Font.PLAIN,14)); detail.setForeground(new Color(74,84,94)); detail.setAlignmentX(Component.LEFT_ALIGNMENT);
        JPanel sourceRow=new JPanel(new BorderLayout(12,0)); sourceRow.setOpaque(false); sourceRow.setAlignmentX(Component.LEFT_ALIGNMENT); sourceRow.setMaximumSize(new Dimension(Integer.MAX_VALUE,22));
        JLabel sourceLeft=new JLabel(" "); sourceLeft.setFont(new Font("SansSerif",Font.BOLD,12)); sourceLeft.setForeground(new Color(92,103,113));
        JLabel sourceRight=new JLabel(" ",SwingConstants.RIGHT); sourceRight.setFont(new Font("SansSerif",Font.BOLD,12)); sourceRight.setForeground(new Color(92,103,113));
        sourceRow.add(sourceLeft,BorderLayout.CENTER); sourceRow.add(sourceRight,BorderLayout.EAST);
        body.add(marker); body.add(headline); body.add(Box.createVerticalStrut(3)); body.add(detail); body.add(Box.createVerticalStrut(4)); body.add(sourceRow);
        wrap.add(body,BorderLayout.CENTER);
        wrap.setMaximumSize(new Dimension(Integer.MAX_VALUE,168));

        if(!stats.curiosityNews.isEmpty()){
            final int[] idx={0};
            Runnable show=()->{
                CuriosityNews q=stats.curiosityNews.get(idx[0]%stats.curiosityNews.size());
                marker.setForeground(q.colour);
                headline.setText("<html><b>"+html(q.headline)+"</b></html>");
                detail.setText("<html><div style='width:540px'>"+html(q.detail)+"</div></html>");
                String hold=q.displayMs>10000 ? "HELD FOR 20 SECONDS" : "";
                sourceLeft.setText(q.source); sourceRight.setText(hold);
                Object timerObj=wrap.getClientProperty("curiosityTimer");
                if(timerObj instanceof javax.swing.Timer)((javax.swing.Timer)timerObj).setDelay(q.displayMs);
                idx[0]=(idx[0]+1)%stats.curiosityNews.size();
            };
            javax.swing.Timer timer=new javax.swing.Timer(stats.curiosityNews.get(0).displayMs,e->show.run());
            timer.setRepeats(true);
            wrap.putClientProperty("curiosityTimer",timer); // retain timer with panel
            show.run(); timer.start();
        }
        // Empty is intentional: heading remains, but no N/A/filler is manufactured.
        return wrap;
    }

    private static void buildRollingResearchNews(FascinatingStats out,java.util.List<Session> all){
        // v15.10.47: broad descriptive curiosity library.  It deliberately favours exact
        // observed/Frozen card evidence because that is the richest complete source.  Each
        // headline names its evidence/player.  No causal or predictive interpretation is made.
        Session mostRed=null,mostBlack=null,mostCardsSession=null,mostPlayerBJ=null,mostDealerBJ=null,mostPush=null;
        int mostRedN=-1,mostBlackN=-1,mostCardsN=-1,mostPlayerBJN=-1,mostDealerBJN=-1,mostPushN=-1;
        int longestPlayerCards=-1,longestDealerCards=-1,longestRoundCards=-1;
        Session lpS=null,ldS=null,lrS=null; int lpH=0,ldH=0,lrH=0;
        int maxTenPlayerRun=0,maxTenDealerRun=0,maxPlayerBJRun=0,maxDealerBJRun=0,maxWinRun=0,maxLossRun=0,maxPushRun=0;
        Session tenPS=null,tenDS=null,pbjS=null,dbjS=null,winS=null,lossS=null,pushS=null;
        int tenPH=0,tenDH=0,pbjH=0,dbjH=0,winH=0,lossH=0,pushH=0;
        int collisionBJ=0,collision20=0,collision21=0; Session collisionBJS=null,collision20S=null,collision21S=null;
        int collisionBJH=0,collision20H=0,collision21H=0;
        double highRedPct=-1,highBlackPct=-1; Session highRedS=null,highBlackS=null;
        int maxRedPlayerRun=0,maxBlackPlayerRun=0,maxRedDealerRun=0,maxBlackDealerRun=0;
        Session redPS=null,blackPS=null,redDS=null,blackDS=null; int redPH=0,blackPH=0,redDH=0,blackDH=0;
        int playerWinsRed=0,playerWinsBlack=0,playerWinsEqual=0,playerWinsColourKnown=0;
        int dealerWinsRed=0,dealerWinsBlack=0,dealerWinsEqual=0,dealerWinsColourKnown=0;
        int playerAllRedWins=0,playerAllBlackWins=0,dealerAllRedWins=0,dealerAllBlackWins=0;
        int playerThreeCardResolved=0,playerThreeCardWins=0,playerFourPlusResolved=0,playerFourPlusWins=0;
        int hearts=0,diamonds=0,clubs=0,spades=0,aces=0;

        // Final release descriptive journey records.  These are calculated only from the
        // numbered observed-session evidence already loaded by Replay.  They do not feed
        // any controller, wager, percentile or live-play decision.
        int reach200Sessions=0, eligibleObservedSessions=0, maxPublicationSession=0;
        int maxPlayerTenStreamRun=0,maxPlayerNonTenStreamRun=0;
        Session playerTenStreamS=null,playerNonTenStreamS=null;
        int playerTenStartH=0,playerTenEndH=0,playerNonTenStartH=0,playerNonTenEndH=0;
        int maxBankWinRun=0,maxBankLossRun=0; double maxBankWinGain=0,maxBankLossDrop=0;
        Session bankWinS=null,bankLossS=null; int bankWinStartH=0,bankWinEndH=0,bankLossStartH=0,bankLossEndH=0;

        for(Session s:all){
            if((s.publicationNumber<=0&&!s.standalonePreamble)||s.hands==null||s.hands.isEmpty())continue;
            maxPublicationSession=Math.max(maxPublicationSession,s.publicationNumber);
            if("20260923_211013".equals(s.id))continue; // formal path is synthetic UAT, not an observed player journey

            eligibleObservedSessions++;
            double observedPeak=Double.NEGATIVE_INFINITY;
            int curBankWin=0,curBankLoss=0,curBankWinStart=0,curBankLossStart=0;
            double curBankWinGain=0,curBankLossDrop=0;
            int curTenStream=0,curNonTenStream=0,curTenStartH=0,curNonTenStartH=0;
            for(Hand hh:s.hands){
                observedPeak=Math.max(observedPeak,Math.max(hh.researchStart(),hh.researchEnd()));
                double delta=hh.researchEnd()-hh.researchStart();
                if(delta>0.004){
                    if(curBankWin==0)curBankWinStart=hh.number; curBankWin++; curBankWinGain+=delta;
                    if(curBankWin>maxBankWinRun || (curBankWin==maxBankWinRun&&curBankWinGain>maxBankWinGain)){maxBankWinRun=curBankWin;maxBankWinGain=curBankWinGain;bankWinS=s;bankWinStartH=curBankWinStart;bankWinEndH=hh.number;}
                    curBankLoss=0;curBankLossDrop=0;
                }else if(delta<-0.004){
                    if(curBankLoss==0)curBankLossStart=hh.number; curBankLoss++; curBankLossDrop+=-delta;
                    if(curBankLoss>maxBankLossRun || (curBankLoss==maxBankLossRun&&curBankLossDrop>maxBankLossDrop)){maxBankLossRun=curBankLoss;maxBankLossDrop=curBankLossDrop;bankLossS=s;bankLossStartH=curBankLossStart;bankLossEndH=hh.number;}
                    curBankWin=0;curBankWinGain=0;
                }else{curBankWin=curBankLoss=0;curBankWinGain=curBankLossDrop=0;}

                for(String cc:playerCardsForHeadline(hh)){
                    boolean ten=isTenValueCard(cc);
                    if(ten){
                        if(curTenStream==0)curTenStartH=hh.number; curTenStream++; curNonTenStream=0;
                        if(curTenStream>maxPlayerTenStreamRun){maxPlayerTenStreamRun=curTenStream;playerTenStreamS=s;playerTenStartH=curTenStartH;playerTenEndH=hh.number;}
                    }else{
                        if(curNonTenStream==0)curNonTenStartH=hh.number; curNonTenStream++; curTenStream=0;
                        if(curNonTenStream>maxPlayerNonTenStreamRun){maxPlayerNonTenStreamRun=curNonTenStream;playerNonTenStreamS=s;playerNonTenStartH=curNonTenStartH;playerNonTenEndH=hh.number;}
                    }
                }
            }
            if(observedPeak>=200.0-0.004)reach200Sessions++;

            int red=0,black=0,total=0,pbj=0,dbj=0,pushes=0,cardsSession=0;
            int curPBJ=0,curDBJ=0,curW=0,curL=0,curP=0;
            for(Hand h:s.hands){
                java.util.List<String> cards=allVisibleCardsForHeadline(h); cardsSession+=cards.size();
                for(String c:cards){
                    if(isRedCard(c))red++;else if(isBlackCard(c))black++; total++;
                    if(c!=null){if(c.contains("♥"))hearts++;else if(c.contains("♦"))diamonds++;else if(c.contains("♣"))clubs++;else if(c.contains("♠"))spades++;}
                    if("A".equals(openingRank(c)))aces++;
                }
                java.util.List<String> pCards=playerCardsForHeadline(h), dCards=dealerCardsForHeadline(h);
                int rpr=longestColourRun(pCards,true), bpr=longestColourRun(pCards,false), rdr=longestColourRun(dCards,true), bdr=longestColourRun(dCards,false);
                if(rpr>maxRedPlayerRun){maxRedPlayerRun=rpr;redPS=s;redPH=h.number;} if(bpr>maxBlackPlayerRun){maxBlackPlayerRun=bpr;blackPS=s;blackPH=h.number;}
                if(rdr>maxRedDealerRun){maxRedDealerRun=rdr;redDS=s;redDH=h.number;} if(bdr>maxBlackDealerRun){maxBlackDealerRun=bdr;blackDS=s;blackDH=h.number;}
                int pc=playerCardCountForHeadline(h), dc=dealerCardCountForHeadline(h), rc=pc+dc;
                if(pc>longestPlayerCards){longestPlayerCards=pc;lpS=s;lpH=h.number;}
                if(dc>longestDealerCards){longestDealerCards=dc;ldS=s;ldH=h.number;}
                if(rc>longestRoundCards){longestRoundCards=rc;lrS=s;lrH=h.number;}
                int pr=longestTenValueRun(playerCardsForHeadline(h)); if(pr>maxTenPlayerRun){maxTenPlayerRun=pr;tenPS=s;tenPH=h.number;}
                int dr=longestTenValueRun(dealerCardsForHeadline(h)); if(dr>maxTenDealerRun){maxTenDealerRun=dr;tenDS=s;tenDH=h.number;}
                boolean pnat=playerNaturalForHeadline(h), dnat=dealerNaturalForHeadline(h);
                if(pnat){pbj++;curPBJ++;}else curPBJ=0; if(curPBJ>maxPlayerBJRun){maxPlayerBJRun=curPBJ;pbjS=s;pbjH=h.number;}
                if(dnat){dbj++;curDBJ++;}else curDBJ=0; if(curDBJ>maxDealerBJRun){maxDealerBJRun=curDBJ;dbjS=s;dbjH=h.number;}
                if(pnat&&dnat){collisionBJ++;collisionBJS=s;collisionBJH=h.number;}
                int pt=playerFinalTotalForHeadline(h),dt=dealerFinalTotalForHeadline(h);
                if(pt==20&&dt==20){collision20++;collision20S=s;collision20H=h.number;}
                if(pt==21&&dt==21){collision21++;collision21S=s;collision21H=h.number;}
                String r=h.result==null?"":h.result.trim().toUpperCase(Locale.ROOT);
                boolean rWin=r.equals("W")||r.contains("WIN");
                boolean rLoss=r.equals("L")||r.contains("LOSS");
                boolean rPush=r.equals("P")||r.contains("PUSH");
                // Simple player-card-count observations: non-split resolved hands only.
                // These are descriptive rates, not action advice.
                if(!h.split && (rWin||rLoss||rPush)){
                    if(pc==3){playerThreeCardResolved++; if(rWin)playerThreeCardWins++;}
                    if(pc>=4){playerFourPlusResolved++; if(rWin)playerFourPlusWins++;}
                }
                if(rWin){
                    int bal=colourBalance(pCards); if(bal==1)playerWinsRed++;else if(bal==-1)playerWinsBlack++;else if(bal==0)playerWinsEqual++; if(bal!=99)playerWinsColourKnown++;
                    if(allOneColour(pCards,true))playerAllRedWins++; if(allOneColour(pCards,false))playerAllBlackWins++;
                } else if(rLoss){
                    int bal=colourBalance(dCards); if(bal==1)dealerWinsRed++;else if(bal==-1)dealerWinsBlack++;else if(bal==0)dealerWinsEqual++; if(bal!=99)dealerWinsColourKnown++;
                    if(allOneColour(dCards,true))dealerAllRedWins++; if(allOneColour(dCards,false))dealerAllBlackWins++;
                }
                if(rPush){pushes++;curP++;curW=curL=0;}else if(rWin){curW++;curL=curP=0;}else if(rLoss){curL++;curW=curP=0;}else{curW=curL=curP=0;}
                if(curW>maxWinRun){maxWinRun=curW;winS=s;winH=h.number;} if(curL>maxLossRun){maxLossRun=curL;lossS=s;lossH=h.number;} if(curP>maxPushRun){maxPushRun=curP;pushS=s;pushH=h.number;}
            }
            if(red>mostRedN){mostRedN=red;mostRed=s;} if(black>mostBlackN){mostBlackN=black;mostBlack=s;} if(cardsSession>mostCardsN){mostCardsN=cardsSession;mostCardsSession=s;}
            if(pbj>mostPlayerBJN){mostPlayerBJN=pbj;mostPlayerBJ=s;} if(dbj>mostDealerBJN){mostDealerBJN=dbj;mostDealerBJ=s;} if(pushes>mostPushN){mostPushN=pushes;mostPush=s;}
            if(total>0){double rp=100.0*red/total,bp=100.0*black/total;if(rp>highRedPct){highRedPct=rp;highRedS=s;}if(bp>highBlackPct){highBlackPct=bp;highBlackS=s;}}
        }
        if(maxWinRun>=2)addCuriosity(out,"LONGEST PLAYER WINNING STREAK",String.format(Locale.ROOT,"%d consecutive recorded wins — %s, Hands %d–%d.",maxWinRun,observedJourneyRef(winS),winH-maxWinRun+1,winH),"OBSERVED PLAYER BANKROLL PATHS (SESSIONS 1–15)",new Color(35,135,90),20000);
        if(maxBankWinRun>=2)addCuriosity(out,"BIGGEST WINNING BANKROLL RUN",String.format(Locale.ROOT,"+£%.2f across %d consecutive bankroll-rising hands — %s, Hands %d–%d.",maxBankWinGain,maxBankWinRun,observedJourneyRef(bankWinS),bankWinStartH,bankWinEndH),"OBSERVED PLAYER BANKROLL PATHS (SESSIONS 1–15)",new Color(35,135,90),20000);
        if(maxBankLossRun>=2)addCuriosity(out,"LONGEST LOSING BANKROLL RUN",String.format(Locale.ROOT,"%d consecutive bankroll-falling hands, −£%.2f — %s, Hands %d–%d.",maxBankLossRun,maxBankLossDrop,observedJourneyRef(bankLossS),bankLossStartH,bankLossEndH),"OBSERVED PLAYER BANKROLL PATHS (SESSIONS 1–15)",new Color(190,65,55),20000);
        if(maxPlayerNonTenStreamRun>0)addCuriosity(out,"LONGEST PLAYER CARD RUN WITHOUT A 10-VALUE CARD",String.format(Locale.ROOT,"%d consecutive player cards without 10/J/Q/K — %s, spanning Hands %d–%d.",maxPlayerNonTenStreamRun,observedJourneyRef(playerNonTenStreamS),playerNonTenStartH,playerNonTenEndH),"OBSERVED PLAYER CARD STREAM",new Color(30,105,170),20000);
        if(maxPlayerTenStreamRun>0)addCuriosity(out,"LONGEST PLAYER 10-VALUE CARD RUN",String.format(Locale.ROOT,"%d consecutive player cards all valued 10 (10/J/Q/K) — %s, spanning Hands %d–%d.",maxPlayerTenStreamRun,observedJourneyRef(playerTenStreamS),playerTenStartH,playerTenEndH),"OBSERVED PLAYER CARD STREAM",new Color(30,105,170),20000);
        if(eligibleObservedSessions>0)addCuriosity(out,"PLAYER JOURNEYS REACHING £200",String.format(Locale.ROOT,"%.1f%% — %d of %d eligible observed bankroll paths reached a research-equivalent bankroll of at least £200.",100.0*reach200Sessions/eligibleObservedSessions,reach200Sessions,eligibleObservedSessions),"OBSERVED PLAYER BANKROLL PATHS (SESSIONS 1–15)",new Color(112,72,155),20000);

        if(playerThreeCardResolved>0)addCuriosity(out,"PLAYER 3-CARD WIN RATE",String.format(Locale.ROOT,"%.1f%% — %d wins from %d resolved player hands that finished with exactly 3 cards.",100.0*playerThreeCardWins/playerThreeCardResolved,playerThreeCardWins,playerThreeCardResolved),"OBSERVED / FROZEN PLAYER — DESCRIPTIVE",new Color(30,105,170),20000);
        if(playerFourPlusResolved>0)addCuriosity(out,"PLAYER 4+ CARD WIN RATE",String.format(Locale.ROOT,"%.1f%% — %d wins from %d resolved player hands that finished with 4 or more cards.",100.0*playerFourPlusWins/playerFourPlusResolved,playerFourPlusWins,playerFourPlusResolved),"OBSERVED / FROZEN PLAYER — DESCRIPTIVE",new Color(30,105,170),20000);
        addCuriosity(out,"MOST RED CARDS IN A SESSION",mostRed==null?"":String.format(Locale.ROOT,"Session %d recorded %d red cards (final bankroll %s).",mostRed.publicationNumber,mostRedN,profitLossLabel(mostRed)),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(190,65,55));
        addCuriosity(out,"MOST BLACK CARDS IN A SESSION",mostBlack==null?"":String.format(Locale.ROOT,"Session %d recorded %d black cards (final bankroll %s).",mostBlack.publicationNumber,mostBlackN,profitLossLabel(mostBlack)),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(55,65,78));
        addCuriosity(out,"HIGHEST RED-CARD SHARE",highRedS==null?"":String.format(Locale.ROOT,"Session %d had %.1f%% red cards (final bankroll %s).",highRedS.publicationNumber,highRedPct,profitLossLabel(highRedS)),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(190,65,55));
        addCuriosity(out,"HIGHEST BLACK-CARD SHARE",highBlackS==null?"":String.format(Locale.ROOT,"Session %d had %.1f%% black cards (final bankroll %s).",highBlackS.publicationNumber,highBlackPct,profitLossLabel(highBlackS)),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(55,65,78));
        if(maxRedPlayerRun>=2)addCuriosity(out,"LONGEST RED-CARD STREAK — PLAYER HAND",String.format(Locale.ROOT,"%d consecutive red cards within one recorded player hand — Session %d, Hand %d.",maxRedPlayerRun,redPS.publicationNumber,redPH),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(190,65,55));
        if(maxBlackPlayerRun>=2)addCuriosity(out,"LONGEST BLACK-CARD STREAK — PLAYER HAND",String.format(Locale.ROOT,"%d consecutive black cards within one recorded player hand — Session %d, Hand %d.",maxBlackPlayerRun,blackPS.publicationNumber,blackPH),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(55,65,78));
        if(maxRedDealerRun>=2)addCuriosity(out,"LONGEST RED-CARD STREAK — DEALER HAND",String.format(Locale.ROOT,"%d consecutive red cards within one recorded dealer hand — Session %d, Hand %d.",maxRedDealerRun,redDS.publicationNumber,redDH),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(190,65,55));
        if(maxBlackDealerRun>=2)addCuriosity(out,"LONGEST BLACK-CARD STREAK — DEALER HAND",String.format(Locale.ROOT,"%d consecutive black cards within one recorded dealer hand — Session %d, Hand %d.",maxBlackDealerRun,blackDS.publicationNumber,blackDH),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(55,65,78));
        if(playerWinsColourKnown>0)addCuriosity(out,"PLAYER WINS — COLOUR BALANCE",String.format(Locale.ROOT,"Of %d player-winning hands with colour evidence: %.1f%% had more red cards, %.1f%% were equal, and %.1f%% had more black cards.",playerWinsColourKnown,100.0*playerWinsRed/playerWinsColourKnown,100.0*playerWinsEqual/playerWinsColourKnown,100.0*playerWinsBlack/playerWinsColourKnown),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(112,72,155));
        if(dealerWinsColourKnown>0)addCuriosity(out,"DEALER WINS — COLOUR BALANCE",String.format(Locale.ROOT,"Of %d dealer-winning hands with colour evidence: %.1f%% had more red cards, %.1f%% were equal, and %.1f%% had more black cards.",dealerWinsColourKnown,100.0*dealerWinsRed/dealerWinsColourKnown,100.0*dealerWinsEqual/dealerWinsColourKnown,100.0*dealerWinsBlack/dealerWinsColourKnown),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(112,72,155));
        if(playerAllRedWins>0)addCuriosity(out,"ALL-RED PLAYER WINS",String.format(Locale.ROOT,"%d recorded player-winning hand%s contained only red cards.",playerAllRedWins,playerAllRedWins==1?"":"s"),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(190,65,55));
        if(playerAllBlackWins>0)addCuriosity(out,"ALL-BLACK PLAYER WINS",String.format(Locale.ROOT,"%d recorded player-winning hand%s contained only black cards.",playerAllBlackWins,playerAllBlackWins==1?"":"s"),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(55,65,78));
        if(dealerAllRedWins>0)addCuriosity(out,"ALL-RED DEALER WINS",String.format(Locale.ROOT,"%d recorded dealer-winning hand%s contained only red cards.",dealerAllRedWins,dealerAllRedWins==1?"":"s"),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(190,65,55));
        if(dealerAllBlackWins>0)addCuriosity(out,"ALL-BLACK DEALER WINS",String.format(Locale.ROOT,"%d recorded dealer-winning hand%s contained only black cards.",dealerAllBlackWins,dealerAllBlackWins==1?"":"s"),"RECONSTRUCTED SUIT COLOUR — DESCRIPTIVE ONLY",new Color(55,65,78));
        int suitMax=Math.max(Math.max(hearts,diamonds),Math.max(clubs,spades));
        String suitName=suitMax==hearts?"HEARTS ♥":suitMax==diamonds?"DIAMONDS ♦":suitMax==clubs?"CLUBS ♣":"SPADES ♠";
        if(suitMax>0)addCuriosity(out,"MOST COMMON RECORDED SUIT",String.format(Locale.ROOT,"%s currently leads the observed evidence with %d recorded cards.",suitName,suitMax),"RECONSTRUCTED SUIT LABELS — DESCRIPTIVE ONLY",new Color(112,72,155));
        if(aces>0)addCuriosity(out,"ACE COUNT",String.format(Locale.ROOT,"%d aces have appeared in the currently loaded observed session evidence.",aces),"OBSERVED TABLE — ELIGIBLE OBSERVED EVIDENCE",new Color(150,95,25));
        addCuriosity(out,"MOST CARDS RECORDED IN ONE SESSION",mostCardsSession==null?"":String.format(Locale.ROOT,"Session %d contains %d recorded player/dealer cards.",mostCardsSession.publicationNumber,mostCardsN),"OBSERVED TABLE",new Color(112,72,155));
        addCuriosity(out,"MOST CARDS IN A PLAYER HAND",lpS==null?"":String.format(Locale.ROOT,"%d cards — Session %d, Hand %d.",longestPlayerCards,lpS.publicationNumber,lpH),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        addCuriosity(out,"MOST CARDS IN A DEALER HAND",ldS==null?"":String.format(Locale.ROOT,"%d cards — Session %d, Hand %d.",longestDealerCards,ldS.publicationNumber,ldH),"OBSERVED DEALER",new Color(50,125,100));
        addCuriosity(out,"MOST CARDS IN A SINGLE ROUND",lrS==null?"":String.format(Locale.ROOT,"%d combined player/dealer cards — Session %d, Hand %d.",longestRoundCards,lrS.publicationNumber,lrH),"OBSERVED TABLE",new Color(112,72,155));
        addCuriosity(out,"MOST PLAYER BLACKJACKS IN A SESSION",mostPlayerBJ==null?"":String.format(Locale.ROOT,"Session %d recorded %d player naturals (final bankroll %s).",mostPlayerBJ.publicationNumber,mostPlayerBJN,profitLossLabel(mostPlayerBJ)),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        addCuriosity(out,"MOST DEALER BLACKJACKS IN A SESSION",mostDealerBJ==null?"":String.format(Locale.ROOT,"Session %d recorded %d dealer naturals.",mostDealerBJ.publicationNumber,mostDealerBJN),"OBSERVED DEALER",new Color(50,125,100));
        if(maxPlayerBJRun>=2)addCuriosity(out,"PLAYER BLACKJACK STREAK",String.format(Locale.ROOT,"%d consecutive player naturals ending at Session %d, Hand %d.",maxPlayerBJRun,pbjS.publicationNumber,pbjH),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        if(maxDealerBJRun>=2)addCuriosity(out,"DEALER BLACKJACK STREAK",String.format(Locale.ROOT,"%d consecutive dealer naturals ending at Session %d, Hand %d.",maxDealerBJRun,dbjS.publicationNumber,dbjH),"OBSERVED DEALER",new Color(50,125,100));
        if(collisionBJ>0)addCuriosity(out,"BLACKJACK COLLISION",String.format(Locale.ROOT,"Player and dealer were both dealt natural blackjack %d time%s; latest at Session %d, Hand %d.",collisionBJ,collisionBJ==1?"":"s",collisionBJS.publicationNumber,collisionBJH),"OBSERVED TABLE",new Color(150,95,25));
        if(collision20>0)addCuriosity(out,"20 vs 20 COLLISION",String.format(Locale.ROOT,"Player and dealer both finished on 20 in %d observed hand%s; latest at Session %d, Hand %d.",collision20,collision20==1?"":"s",collision20S.publicationNumber,collision20H),"OBSERVED TABLE",new Color(112,72,155));
        if(collision21>0)addCuriosity(out,"21 vs 21 COLLISION",String.format(Locale.ROOT,"Player and dealer both finished on 21 in %d observed hand%s; latest at Session %d, Hand %d.",collision21,collision21==1?"":"s",collision21S.publicationNumber,collision21H),"OBSERVED TABLE",new Color(112,72,155));
        if(maxPushRun>=2)addCuriosity(out,"LONGEST PUSH STREAK",String.format(Locale.ROOT,"%d consecutive pushes ending at Session %d, Hand %d.",maxPushRun,pushS.publicationNumber,pushH),"OBSERVED / FROZEN PLAYER",new Color(100,105,115));
        if(mostPush!=null)addCuriosity(out,"MOST PUSHES IN A SESSION",String.format(Locale.ROOT,"Session %d recorded %d pushes (final bankroll %s).",mostPush.publicationNumber,mostPushN,profitLossLabel(mostPush)),"OBSERVED / FROZEN PLAYER",new Color(100,105,115));

        // A small number of reconstructed-player headlines are allowed when exact runtime evidence exists.
        TenBustPoint cb=null,cw=null; for(TenBustPoint p:out.casualPoints){if(!p.available)continue;if(cb==null||p.finalBankroll>cb.finalBankroll)cb=p;if(cw==null||p.finalBankroll<cw.finalBankroll)cw=p;}
        if(cb!=null)addCuriosity(out,"HIGHEST SOURCE-SUPPORTED CASUAL FINISH",String.format(Locale.ROOT,"%s finished at £%.2f in the exact reconstructed Casual trace.",cb.label,cb.finalBankroll),"CASUAL PLAYER — SOURCE-SUPPORTED TRACE",new Color(205,112,30));
        if(cw!=null&&cw!=cb)addCuriosity(out,"LOWEST SOURCE-SUPPORTED CASUAL FINISH",String.format(Locale.ROOT,"%s finished at £%.2f in the exact reconstructed Casual trace.",cw.label,cw.finalBankroll),"CASUAL PLAYER — SOURCE-SUPPORTED TRACE",new Color(205,112,30));
    }

    private static void addCuriosity(FascinatingStats out,String h,String d,String src,Color c){if(d!=null&&!d.isBlank())out.curiosityNews.add(new CuriosityNews(h,d,src,c));}
    private static void addCuriosity(FascinatingStats out,String h,String d,String src,Color c,int displayMs){if(d!=null&&!d.isBlank())out.curiosityNews.add(new CuriosityNews(h,d,src,c,displayMs));}
    private static boolean isRedCard(String c){return c!=null&&(c.contains("♥")||c.contains("♦"));}
    private static boolean isBlackCard(String c){return c!=null&&(c.contains("♣")||c.contains("♠"));}
    private static java.util.List<String> playerCardsForHeadline(Hand h){java.util.List<String>z=new ArrayList<>();if(h==null)return z;if(h.split){if(h.splitA!=null)z.addAll(h.splitA);if(h.splitB!=null)z.addAll(h.splitB);}else if(h.player!=null)z.addAll(h.player);return z;}
    private static java.util.List<String> dealerCardsForHeadline(Hand h){java.util.List<String>z=new ArrayList<>();if(h==null)return z;if(h.dealerUp!=null&&!h.dealerUp.equals("?"))z.add(h.dealerUp);if(h.dealerHidden!=null)z.addAll(h.dealerHidden);return z;}
    private static int dealerCardCountForHeadline(Hand h){return dealerCardsForHeadline(h).size();}
    private static int longestTenValueRun(java.util.List<String> cards){int best=0,cur=0;for(String c:cards){if(isTenValueCard(c)){cur++;best=Math.max(best,cur);}else cur=0;}return best;}
    private static int longestColourRun(java.util.List<String> cards,boolean red){int best=0,cur=0;for(String c:cards){boolean hit=red?isRedCard(c):isBlackCard(c);if(hit){cur++;best=Math.max(best,cur);}else cur=0;}return best;}
    private static int colourBalance(java.util.List<String> cards){if(cards==null||cards.isEmpty())return 99;int red=0,black=0;for(String c:cards){if(isRedCard(c))red++;else if(isBlackCard(c))black++;}if(red+black==0)return 99;return red>black?1:black>red?-1:0;}
    private static boolean allOneColour(java.util.List<String> cards,boolean red){if(cards==null||cards.isEmpty())return false;for(String c:cards){if(red&&!isRedCard(c))return false;if(!red&&!isBlackCard(c))return false;}return true;}
    private static boolean playerNaturalForHeadline(Hand h){java.util.List<String>c=playerCardsForHeadline(h);return !h.split&&c.size()==2&&cardTotal(c)==21;}
    private static boolean dealerNaturalForHeadline(Hand h){java.util.List<String>c=dealerCardsForHeadline(h);return c.size()>=2&&cardTotal(c.subList(0,2))==21;}
    private static int playerFinalTotalForHeadline(Hand h){if(h==null||h.split)return -1;return cardTotal(h.player);}
    private static int dealerFinalTotalForHeadline(Hand h){java.util.List<String>c=dealerCardsForHeadline(h);return c.isEmpty()?-1:cardTotal(c);}
    private static String profitLossLabel(Session s){double v=s.hands.get(s.hands.size()-1).researchEnd()-100.0;return (v>0?"+£":v<0?"−£":"£")+String.format(Locale.ROOT,"%.2f",Math.abs(v));}

    private static int playerCardCountForHeadline(Hand h){
        if(h==null)return 0; if(h.split)return (h.splitA==null?0:h.splitA.size())+(h.splitB==null?0:h.splitB.size()); return h.player==null?0:h.player.size();
    }
    private static java.util.List<String> allVisibleCardsForHeadline(Hand h){
        java.util.List<String> z=new ArrayList<>(); if(h==null)return z;
        z.addAll(playerCardsForHeadline(h)); z.addAll(dealerCardsForHeadline(h)); return z;
    }

    private JPanel fascinatingMetricCard(String heading, String value, String detail) {
        JPanel p = new JPanel();
        p.setBackground(new Color(238, 244, 249));
        p.setBorder(new CompoundBorder(new LineBorder(new Color(218, 226, 233)), new EmptyBorder(8, 8, 7, 8)));
        p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
        JLabel h = new JLabel(heading, SwingConstants.CENTER);
        h.setFont(new Font("SansSerif", Font.BOLD, 11)); h.setForeground(new Color(55, 72, 88)); h.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel v = new JLabel(value, SwingConstants.CENTER);
        v.setFont(new Font("SansSerif", Font.BOLD, 25)); v.setForeground(NAVY); v.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel d = new JLabel(detail, SwingConstants.CENTER);
        d.setFont(new Font("SansSerif", Font.PLAIN, 10)); d.setForeground(new Color(100, 108, 116)); d.setAlignmentX(Component.CENTER_ALIGNMENT);
        p.add(h); p.add(Box.createVerticalStrut(1)); p.add(v); p.add(d);
        return p;
    }

    private static int chartPreambleHands(Session s) {
        Matcher m=Pattern.compile("(?i)PREAMBLE\\s+HANDS\\s*:\s*(\\d+)").matcher(s.sessionName==null?"":s.sessionName);
        if(m.find()) try{return Integer.parseInt(m.group(1));}catch(Exception ignored){}
        // Session 7 preamble was historically recorded outside the formal preamble subsystem.
        if(s.publicationNumber==7) return 13;
        return 0;
    }

    private FascinatingStats fascinatingStats(java.util.List<Session> all) {
        FascinatingStats out = new FascinatingStats();
        for (Session s : all) {
            // The parser can expose retained correction/reconstruction blocks with
            // publicationNumber 0.  Fascinating Stats Corner is intentionally
            // limited to accountable observed journeys: numbered observed sessions plus
            // publication-verified standalone preambles. Correction/reconstruction blocks
            // remain excluded so they can never be counted as independent journeys.
            if (s.publicationNumber <= 0 && !s.standalonePreamble) continue;
            int tenBusts = 0;
            for (Hand h : s.hands) {
                if (dealerStartsTenTen(h)) out.dealerTenTen++;
                tenBusts += playerTenCardBustCount(h);
            }
            out.totalPlayerTenBusts += tenBusts;
            if (!s.hands.isEmpty()) {
                double finalBankroll = s.hands.get(s.hands.size()-1).researchEnd();
                boolean positive = finalBankroll > 100.000001;
                if (tenBusts > 0) {
                    out.sessionsWithTenBust++;
                    if (positive) out.positiveWithTenBust++;
                }
                String label = s.standalonePreamble ? "PRE" : "S" + s.publicationNumber;
                boolean chartPreamble = s.hadPreamble || s.publicationNumber==7;
                int chartPreambleHands = chartPreambleHands(s);
                out.points.add(TenBustPoint.measured(label, finalBankroll, tenBusts, chartPreamble, chartPreambleHands));

                // v13.2: distinguish "no Casual reconstruction" from "a historical Casual
                // replay exists but its hand-by-hand card trace is not present in the current
                // runtime evidence".  A missing trace must never be rendered as zero, and it
                // must not imply that the historical Casual journey itself did not exist.
                if (isSourceVerifiedCasualBlock(loadedOutputText, s.id)) {
                    try {
                        List<CompareHand> casual = buildCasualTrace(loadedOutputText, s.id);
                        int casualBusts = 0;
                        for (CompareHand ch : casual) casualBusts += compareHandTenCardBustCount(ch);
                        if (!casual.isEmpty()) {
                            double casualFinal = casual.get(casual.size()-1).after;
                            out.casualPoints.add(TenBustPoint.measured(label, casualFinal, casualBusts, chartPreamble, chartPreambleHands));
                        } else out.casualPoints.add(TenBustPoint.unavailable(label, "N/A", chartPreamble, chartPreambleHands));
                    } catch (Exception ex) {
                        out.casualPoints.add(TenBustPoint.unavailable(label, "N/A", chartPreamble, chartPreambleHands));
                    }
                } else {
                    HistoricalCasualMeta hm = historicalCasualMeta(s.id);
                    if (hm != null) {
                        out.casualPoints.add(TenBustPoint.historicalTraceMissing(label, hm.finalBankroll));
                    } else {
                        out.casualPoints.add(TenBustPoint.unavailable(label, "N/A", chartPreamble, chartPreambleHands));
                    }
                }
            }
        }
        buildRollingResearchNews(out, all);
        out.statWatchPoints = allStatWatch(all);
        out.ultimatePoints = allUltimateAI(all);
        out.latestStatWatch = out.statWatchPoints.isEmpty()?null:out.statWatchPoints.get(out.statWatchPoints.size()-1);
        return out;
    }

    private java.util.List<StatWatchView> allStatWatch(java.util.List<Session> all) {
        java.util.List<StatWatchView> out=new ArrayList<>();
        for(Session s:all){
            if(s.publicationNumber<=0)continue;
            StatWatchView v=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);
            if(v!=null&&!Double.isNaN(v.chosenBank)){
                computeStatWatchWagerSummary(v);
                v.frozenComparableBank=frozenComparableBankroll(s,v);
                out.add(v);
            }
        }
        return out;
    }

    private java.util.List<UltimateAIView> allUltimateAI(java.util.List<Session> all){
        java.util.List<UltimateAIView> out=new ArrayList<>();
        for(Session s:all){if(s.publicationNumber<9)continue;UltimateAIView v=parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);if(v!=null&&!Double.isNaN(v.aiBank)&&(v.aiComplete||(v.path!=null&&v.path.size()>1))){if("PREAMBLE_START".equalsIgnoreCase(v.basis))v.label=v.label+" PRE";out.add(v);}}return out;
    }

    private double frozenComparableBankroll(Session s,StatWatchView v){
        if(s==null||v==null)return Double.NaN;
        if(v.preambleStart){
            String sid=Pattern.quote(s.id);
            Pattern p=Pattern.compile("(?s)=+\\s*PREAMBLE-START COUNTERFACTUAL \\| SESSION "+sid+" =+.*?Frozen from preamble start: hands \\d+ \\| final £([0-9.]+)");
            Matcher m=p.matcher(loadedOutputText==null?"":loadedOutputText);
            if(m.find())try{return Double.parseDouble(m.group(1));}catch(Exception ignored){}
            return Double.NaN;
        }
        if(s.hands==null||s.hands.isEmpty())return Double.NaN;
        return s.hands.get(s.hands.size()-1).researchEnd();
    }

    private static void computeStatWatchWagerSummary(StatWatchView v){
        if(v==null||v.chosenHands<=0)return;
        double total=15.0*v.chosenHands, max=15.0;
        for(String x:v.influences){
            Matcher m=Pattern.compile("Hand (\\d+) — WAGER: \\d+x wager \\(£([0-9.]+)\\)").matcher(x);
            if(m.find()){
                try{double wager=Double.parseDouble(m.group(2)); total+=wager-15.0; max=Math.max(max,wager);}catch(Exception ignored){}
            }
        }
        v.meanInitialWager=total/v.chosenHands; v.maxInitialWager=max;
    }

    private static boolean isSourceVerifiedCasualBlock(String text, String sid) {
        if (text == null || sid == null) return false;
        int a = text.indexOf("================ SESSION " + sid + " ================");
        if (a < 0) return false;
        int b = text.indexOf("================ SESSION ", a + 20);
        if (b < 0) b = text.length();
        String block = text.substring(a, b);
        return block.contains("Casual reconstructed:") && block.contains("RECONSTRUCTION STATUS: SOURCE-VERIFIED");
    }

    // Frozen historical register used only to state that an earlier Casual replay existed
    // and to show its already-audited closing bankroll.  It is NOT used to manufacture a
    // 10-card-bust count.  Until an exact historical hand trace is available, that count is
    // shown as TRACE N/A.  Sessions 4, 6 and 7 intentionally have no entry here because their
    // observed-source Casual reconstruction is incomplete/not source-verified.
    private static HistoricalCasualMeta historicalCasualMeta(String sid) {
        if (sid == null) return null;
        if (sid.contains("20260903_004704")) return new HistoricalCasualMeta(347.50);
        if (sid.contains("20260903_221223")) return new HistoricalCasualMeta(160.00);
        if (sid.contains("20260905_174453")) return new HistoricalCasualMeta(182.50);
        return null;
    }

    private static class HistoricalCasualMeta {
        final double finalBankroll;
        HistoricalCasualMeta(double finalBankroll) { this.finalBankroll = finalBankroll; }
    }

    private static int compareHandTenCardBustCount(CompareHand h) {
        int n = 0;
        if (h.branches != null && !h.branches.isEmpty()) {
            for (List<String> seq : h.branches) if (sequenceBustByTen(seq)) n++;
        } else if (sequenceBustByTen(h.player)) n++;
        return n;
    }

    private static boolean dealerStartsTenTen(Hand h) {
        if (!isTenValueCard(h.dealerUp) || h.dealerHidden.isEmpty()) return false;
        return isTenValueCard(h.dealerHidden.get(0));
    }

    private static int playerTenCardBustCount(Hand h) {
        if (h.split) {
            int n = 0;
            if (sequenceBustByTen(h.splitA)) n++;
            if (sequenceBustByTen(h.splitB)) n++;
            return n;
        }
        return sequenceBustByTen(h.player) ? 1 : 0;
    }

    private static boolean sequenceBustByTen(List<String> cards) {
        if (cards == null || cards.size() < 3) return false;
        if (!isTenValueCard(cards.get(cards.size()-1))) return false;
        List<String> before = cards.subList(0, cards.size()-1);
        return cardTotal(before) <= 21 && cardTotal(cards) > 21;
    }

    private static boolean isTenValueCard(String card) {
        String r = openingRank(card);
        return r.equals("10") || r.equals("J") || r.equals("Q") || r.equals("K");
    }

    private static class CuriosityNews {
        final String headline,detail,source; final Color colour; final int displayMs;
        CuriosityNews(String headline,String detail,String source,Color colour){this(headline,detail,source,colour,10000);}
        CuriosityNews(String headline,String detail,String source,Color colour,int displayMs){this.headline=headline;this.detail=detail;this.source=source;this.colour=colour;this.displayMs=displayMs;}
    }

    private static class FascinatingStats {
        int dealerTenTen = 0;
        int totalPlayerTenBusts = 0;
        int sessionsWithTenBust = 0;
        int positiveWithTenBust = 0;
        java.util.List<TenBustPoint> points = new ArrayList<>();
        java.util.List<TenBustPoint> casualPoints = new ArrayList<>();
        java.util.List<CuriosityNews> curiosityNews = new ArrayList<>();
        java.util.List<StatWatchView> statWatchPoints = new ArrayList<>();
        java.util.List<UltimateAIView> ultimatePoints = new ArrayList<>();
        StatWatchView latestStatWatch = null;
    }

    private static class UltimateAIView {
        String label="",basis="",termination=""; int hands=0,frozenHands=0,actionOverrides=0,wagerOverrides=0,exitOverrides=0,wins=-1,losses=-1,pushes=-1,repositoryThrough=-1;double aiBank=Double.NaN,frozenBank=Double.NaN,exposure=Double.NaN;boolean aiComplete=false,frozenComplete=false;java.util.List<Double> path=new ArrayList<>(),stayPath=new ArrayList<>();java.util.List<String> overrideEvents=new ArrayList<>();
    }
    private static UltimateAIView parseUltimateAIBlock(String text,String sid,String label){
        if(text==null||sid==null)return null;String start="================ ULTIMATE AI | SESSION "+sid+" ================";int a=text.indexOf(start);if(a<0)return null;
        // v15.10.282: align Session 14 summary authority with the corrected rich trace.
        if("20260920_003052".equals(sid)){int scan=a;while(scan>=0){int end=text.indexOf("================ END ULTIMATE AI | SESSION "+sid+" ================",scan);if(end<0)end=text.length();String candidate=text.substring(scan,end);if(Pattern.compile("Ultimate AI: hands\\s+25\\s+\\|\\s+bankroll\\s+£?32\\.50",Pattern.CASE_INSENSITIVE).matcher(candidate).find()){a=scan;break;}scan=text.indexOf(start,end);}}
        int b=text.indexOf("================ END ULTIMATE AI | SESSION "+sid+" ================",a);if(b<0)b=text.length();String block=text.substring(a,b);UltimateAIView v=new UltimateAIView();v.label=label;Matcher m=Pattern.compile("REPLAY BASIS: ([A-Z_]+)").matcher(block);if(m.find())v.basis=m.group(1);Matcher rm=Pattern.compile("repository through Session (\\d+)",Pattern.CASE_INSENSITIVE).matcher(block);if(rm.find())v.repositoryThrough=Integer.parseInt(rm.group(1));Matcher om=Pattern.compile("(?m)^AI_OVERRIDE \\| H(\\d+) ([A-Z]+) \\| (.+)$").matcher(block);while(om.find()){String kind=om.group(2),detail=om.group(3).trim();v.overrideEvents.add("Hand "+om.group(1)+" — "+kind+": "+detail);}m=Pattern.compile("Ultimate AI: hands (\\d+) \\| bankroll (?:£([0-9.]+)|N/A)(?: \\| last source-supported £([0-9.]+))? \\| P/L [^|]+\\| W/L/P (\\d+)/(\\d+)/(\\d+) \\| exposure £([0-9.]+) \\| termination ([A-Z_]+) \\| (SOURCE-COMPLETE|NOT SOURCE-COMPLETE)").matcher(block);if(m.find()){v.hands=Integer.parseInt(m.group(1));String bank=m.group(2)!=null?m.group(2):m.group(3);if(bank!=null)v.aiBank=Double.parseDouble(bank);v.wins=Integer.parseInt(m.group(4));v.losses=Integer.parseInt(m.group(5));v.pushes=Integer.parseInt(m.group(6));v.exposure=Double.parseDouble(m.group(7));v.termination=m.group(8);v.aiComplete=m.group(9).equals("SOURCE-COMPLETE");}m=Pattern.compile("Frozen same-basis comparator: hands (\\d+) \\| bankroll (?:£([0-9.]+)|N/A).*?\\| termination ([A-Z_]+) \\| (SOURCE-COMPLETE|NOT SOURCE-COMPLETE)").matcher(block);if(m.find()){v.frozenHands=Integer.parseInt(m.group(1));if(m.group(2)!=null)v.frozenBank=Double.parseDouble(m.group(2));v.frozenComplete=m.group(4).equals("SOURCE-COMPLETE");}m=Pattern.compile("OVERRIDES: action (\\d+) \\| wager (\\d+) \\| exit (\\d+)").matcher(block);if(m.find()){v.actionOverrides=Integer.parseInt(m.group(1));v.wagerOverrides=Integer.parseInt(m.group(2));v.exitOverrides=Integer.parseInt(m.group(3));}Matcher pm=Pattern.compile("(?m)^PATH_AI: (.+)$").matcher(block);if(pm.find())v.path=parseBankPath(pm.group(1));Matcher sm=Pattern.compile("(?m)^PATH_AI_STAY: (.+)$").matcher(block);if(sm.find())v.stayPath=parseBankPath(sm.group(1));
        // v15.10.111: append-only completion semantics may supersede only the publication classification.
        // The original AI replay block remains the behavioural evidence; this addendum never changes decisions.
        Pattern cp=Pattern.compile("(?s)SESSION 10 ULTIMATE AI COMPLETION-SEMANTICS ADDENDUM.*?SESSION: "+Pattern.quote(sid)+".*?SESSION 10 RESULT: final bankroll £([0-9.]+) \\| P/L [^|]+\\| hands (\\d+) \\| W/L/P (\\d+)/(\\d+)/(\\d+) \\| exposure £([0-9.]+) \\| termination ([A-Z_]+) \\| SOURCE-COMPLETE");
        Matcher cm=cp.matcher(text);if(cm.find()){v.aiBank=Double.parseDouble(cm.group(1));v.hands=Integer.parseInt(cm.group(2));v.wins=Integer.parseInt(cm.group(3));v.losses=Integer.parseInt(cm.group(4));v.pushes=Integer.parseInt(cm.group(5));v.exposure=Double.parseDouble(cm.group(6));v.termination=cm.group(7);v.aiComplete=true;}
        Pattern cc=Pattern.compile("ULTIMATE_AI_COMPLETION_CORRECTION \\| session "+Pattern.quote(sid)+" \\| PASS \\| AI_EXIT SOURCE-COMPLETE \\| final £([0-9.]+)",Pattern.CASE_INSENSITIVE);
        Matcher ccm=cc.matcher(text);if(ccm.find()&&"AI_EXIT".equalsIgnoreCase(v.termination)){v.aiBank=Double.parseDouble(ccm.group(1));v.aiComplete=true;}
        return v;
    }

    private static class UltimateAIBarPanel extends JPanel {
        final java.util.List<UltimateAIView> pts,cumulativePts;UltimateAIBarPanel(java.util.List<UltimateAIView> p,java.util.List<UltimateAIView> c){pts=p;cumulativePts=c;setOpaque(true);setBackground(Color.WHITE);setBorder(new LineBorder(new Color(226,231,235)));setToolTipText("Ultimate AI begins at Session 9. Solid = source-complete AI final bankroll; dashed AI = source-exhausted last-supported bankroll; outline = same-basis Frozen. Running totals include source-complete AI sessions only.");}
        @Override protected void paintComponent(Graphics g){super.paintComponent(g);Graphics2D g2=(Graphics2D)g.create();g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int w=getWidth(),h=getHeight(),left=198,right=24,top=112,bottom=54,pw=Math.max(60,w-left-right),ph=Math.max(60,h-top-bottom);double aip=0,fp=0;boolean fc=true;int nSup=0;for(UltimateAIView v:cumulativePts){if(v.aiComplete&&!Double.isNaN(v.aiBank)){aip+=v.aiBank-100;nSup++;}if(v.aiComplete&&!Double.isNaN(v.frozenBank))fp+=v.frozenBank-100;else if(v.aiComplete)fc=false;}int sx=16,sy=112;g2.setColor(NAVY);g2.setFont(new Font("SansSerif",Font.BOLD,12));g2.drawString("RUNNING PROFIT / LOSS",sx,sy);g2.setFont(new Font("SansSerif",Font.BOLD,11));g2.setColor(new Color(51,66,82));g2.drawString("Ultimate AI",sx,sy+28);g2.setFont(new Font("SansSerif",Font.BOLD,15));g2.setColor(new Color(64,91,122));g2.drawString(StatWatchBarPanel.moneySigned(aip),sx,sy+48);g2.setColor(new Color(225,230,234));g2.drawLine(sx,sy+62,left-22,sy+62);g2.setFont(new Font("SansSerif",Font.BOLD,10));g2.setColor(NAVY);g2.drawString("RUNNING PROFIT — FROZEN",sx,sy+84);g2.setFont(new Font("SansSerif",Font.PLAIN,9));g2.setColor(new Color(86,96,105));g2.drawString("Same "+nSup+" AI-era session"+(nSup==1?"":"s"),sx,sy+99);g2.setFont(new Font("SansSerif",Font.BOLD,14));g2.setColor(new Color(40,82,109));if(fc)g2.drawString(StatWatchBarPanel.moneySigned(fp),sx,sy+121);else{g2.setFont(new Font("SansSerif",Font.BOLD,11));g2.drawString("N/A — SOURCE EXHAUSTED",sx,sy+121);}
            double max=100;for(UltimateAIView v:pts){if(!Double.isNaN(v.aiBank))max=Math.max(max,v.aiBank);if(!Double.isNaN(v.frozenBank))max=Math.max(max,v.frozenBank);}max=Math.ceil((max+25)/50.0)*50;g2.setFont(new Font("SansSerif",Font.PLAIN,9));for(int k=0;k<=4;k++){double val=max*k/4.0;int y=top+ph-(int)Math.round(val/max*ph);g2.setColor(new Color(232,236,239));g2.drawLine(left,y,left+pw,y);g2.setColor(new Color(92,102,110));String tick="£"+String.format(Locale.ROOT,"%.0f",val);g2.drawString(tick,left-12-g2.getFontMetrics().stringWidth(tick),y+3);}int n=Math.max(1,pts.size()),layoutN=n,slot=Math.max(1,pw/layoutN),bar=Math.max(9,Math.min(16,Math.max(9,(slot-20)/2))),base=top+ph;for(int i=0;i<pts.size();i++){UltimateAIView v=pts.get(i);int cx=left+(int)Math.round((i+0.5)*pw/layoutN);if(!Double.isNaN(v.aiBank)){int y=top+ph-(int)Math.round(v.aiBank/max*ph);g2.setColor(new Color(64,91,122));if(v.aiComplete){g2.fillRoundRect(cx-bar-3,y,bar,Math.max(1,base-y),5,5);}else{Stroke os=g2.getStroke();g2.setStroke(new BasicStroke(1.8f,BasicStroke.CAP_BUTT,BasicStroke.JOIN_ROUND,1f,new float[]{5f,4f},0f));g2.drawRoundRect(cx-bar-3,y,bar,Math.max(1,base-y),5,5);g2.setStroke(os);}if("S14".equalsIgnoreCase(v.label)) StatWatchBarPanel.drawLiveCasinoInsideBar(g2,cx-bar-3,y,bar,Math.max(1,base-y),v.aiComplete?Color.WHITE:new Color(64,91,122));String al=v.aiComplete?("AI H"+v.hands+"  £"+String.format(Locale.ROOT,"%.2f",v.aiBank)):("AI H"+v.hands+"  £"+String.format(Locale.ROOT,"%.2f",v.aiBank)+"  LAST SUPPORTED");StatWatchBarPanel.drawVerticalLabelAboveBar(g2,al,cx-bar/2-3,y-5,new Font("SansSerif",Font.BOLD,10),new Color(45,57,68));}if(!Double.isNaN(v.frozenBank)){int y=top+ph-(int)Math.round(v.frozenBank/max*ph);g2.setColor(new Color(112,124,136));g2.setStroke(new BasicStroke(1.6f));g2.drawRoundRect(cx+3,y,bar,Math.max(1,base-y),5,5);if("S14".equalsIgnoreCase(v.label)) StatWatchBarPanel.drawLiveCasinoInsideBar(g2,cx+3,y,bar,Math.max(1,base-y),Color.BLACK);StatWatchBarPanel.drawVerticalLabelAboveBar(g2,"FROZEN H"+v.frozenHands+"  £"+String.format(Locale.ROOT,"%.2f",v.frozenBank),cx+3+bar/2,y-5,new Font("SansSerif",Font.PLAIN,10),new Color(100,112,124));}// v15.10.315: keep the session identifier with the same vertical reading direction as the evidence labels.
                // This removes the compressed S9/S10/... baseline row and gives each Ultimate-AI session a distinct visual lane.
                StatWatchBarPanel.drawVerticalLabel(g2,v.label,cx+4,base+40,new Font("SansSerif",Font.BOLD,9),new Color(62,72,81));}g2.dispose();}
    }

    private static class StatWatchView {
        String label="", status=""; int exitHand=0,chosenHands=0,stayHands=0,preambleHands=0; double chosenBank=Double.NaN,exitBank=Double.NaN,stayBank=Double.NaN,chosenExposure=Double.NaN;
        double frozenComparableBank=Double.NaN, meanInitialWager=Double.NaN, maxInitialWager=Double.NaN;
        String chosenTermination="",stayTermination="",exitReason=""; boolean staySourceComplete=false,preambleStart=false;
        java.util.List<Double> chosenPath=new ArrayList<>(),stayPath=new ArrayList<>(); java.util.List<String> influences=new ArrayList<>();
    }

    private static StatWatchView parseStatWatchBlock(String text,String sid,String label){
        if(text==null||sid==null)return null;String start="================ STAT-WATCHING CASUAL | SESSION "+sid+" ================";int a=text.lastIndexOf(start);if(a<0)return null;int b=text.indexOf("================ END STAT-WATCHING CASUAL | SESSION "+sid+" ================",a);if(b<0)b=text.length();String block=text.substring(a,b);StatWatchView v=new StatWatchView();v.label=label;v.preambleStart=block.contains("REPLAY BASIS: PREAMBLE_START");
        Matcher pre=Pattern.compile("PREAMBLE CONTEXT:.*?preamble hands (\\d+)").matcher(block);if(pre.find())v.preambleHands=Integer.parseInt(pre.group(1));
        Matcher m=Pattern.compile("Chosen behavioural path: hands (\\d+) \\| bankroll £([0-9.]+).*?exposure £([0-9.]+).*?termination ([A-Z_]+)(?: \\| chosen exit Hand (\\d+) at £([0-9.]+))?").matcher(block);if(m.find()){v.chosenHands=Integer.parseInt(m.group(1));v.chosenBank=Double.parseDouble(m.group(2));v.chosenExposure=Double.parseDouble(m.group(3));v.chosenTermination=m.group(4);if(m.group(5)!=null){v.exitHand=Integer.parseInt(m.group(5));v.exitBank=Double.parseDouble(m.group(6));}}
        m=Pattern.compile("Stay-at-table counterfactual: hands (\\d+) \\| bankroll £([0-9.]+).*?termination ([A-Z_]+) \\| (SOURCE-COMPLETE|NOT SOURCE-COMPLETE)").matcher(block);if(m.find()){v.stayHands=Integer.parseInt(m.group(1));v.stayBank=Double.parseDouble(m.group(2));v.stayTermination=m.group(3);v.staySourceComplete=m.group(4).equals("SOURCE-COMPLETE");}
        Matcher pm=Pattern.compile("(?m)^PATH_CHOSEN: (.+)$").matcher(block);if(pm.find())v.chosenPath=parseBankPath(pm.group(1));pm=Pattern.compile("(?m)^PATH_STAY: (.+)$").matcher(block);if(pm.find())v.stayPath=parseBankPath(pm.group(1));
        Matcher im=Pattern.compile("(?m)^INFLUENCE \\| H?(\\d+|NONE) \\| ([^|]+) \\| (.+)$").matcher(block);while(im.find()){if(!im.group(1).equals("NONE")){String kind=im.group(2).trim(),detail=im.group(3).trim();v.influences.add("Hand "+im.group(1)+" — "+kind+": "+detail);if(kind.equals("EXIT"))v.exitReason=detail;}}
        return v;
    }
    private static java.util.List<Double> parseBankPath(String raw){java.util.List<Double>x=new ArrayList<>();for(String q:raw.trim().split(";")){int eq=q.indexOf('=');if(eq<0)continue;try{x.add(Double.parseDouble(q.substring(eq+1).trim()));}catch(Exception ignored){}}return x;}
    private static String html(String x){return x==null?"":x.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;");}
    private static String storyHtml(String x){
        String safe=html(x);
        if(safe.startsWith("✓ ")) safe="<span style='color:#188038;font-weight:bold'>✓</span> "+safe.substring(2);
        else if(safe.startsWith("✗ ")) safe="<span style='color:#c62828;font-weight:bold'>✗</span> "+safe.substring(2);
        else if(safe.startsWith("— ")) safe="<span style='color:#1769aa;font-weight:bold'>—</span> "+safe.substring(2);
        safe=safe.replace("✓ =","<span style='color:#188038;font-weight:bold'>✓</span> =")
                 .replace("✗ =","<span style='color:#c62828;font-weight:bold'>✗</span> =")
                 .replace("— =","<span style='color:#1769aa;font-weight:bold'>—</span> =");
        return safe.replaceAll("\\bHand (\\d+)\\b","<b>Hand $1</b>");
    }

    private static class StatWatchBarPanel extends JPanel {
        final java.util.List<StatWatchView> pts;
        final java.util.List<StatWatchView> cumulativePts;
        StatWatchBarPanel(java.util.List<StatWatchView> pts){this(pts,pts);}
        StatWatchBarPanel(java.util.List<StatWatchView> pts,java.util.List<StatWatchView> cumulativePts){this.pts=pts;this.cumulativePts=cumulativePts;setOpaque(true);setBackground(Color.WHITE);setBorder(new LineBorder(new Color(226,231,235)));setToolTipText("<html><div style='width:340px'>Solid bar = chosen final bankroll when he actually stopped/ended.<br>Outlined bar = final bankroll if only his walk-away decision is ignored.<br>PRE = replay began at observed preamble start.<br>Running profit is cumulative through the last session on this chart.</div></html>");}
        @Override protected void paintComponent(Graphics g){
            super.paintComponent(g); Graphics2D g2=(Graphics2D)g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth(),h=getHeight(),left=145,right=24,top=104,bottom=84,pw=Math.max(40,w-left-right),ph=Math.max(60,h-top-bottom);

            double chosenRunningProfit=0.0, stayedRunningProfit=0.0, frozenRunningProfit=0.0;
            boolean stayedTotalComplete=true, frozenTotalComplete=true; int supportedSessions=0;
            for(StatWatchView v:cumulativePts){
                double chosen=v.exitHand>0?v.exitBank:v.chosenBank;
                if(!Double.isNaN(chosen)){chosenRunningProfit+=chosen-100.0;supportedSessions++;}
                if(!Double.isNaN(v.stayBank))stayedRunningProfit+=v.stayBank-100.0; else stayedTotalComplete=false;
                if(!Double.isNaN(v.frozenComparableBank))frozenRunningProfit+=v.frozenComparableBank-100.0; else frozenTotalComplete=false;
            }
            double max=100; for(StatWatchView v:pts){if(!Double.isNaN(v.chosenBank))max=Math.max(max,v.chosenBank);if(!Double.isNaN(v.stayBank))max=Math.max(max,v.stayBank);} max=Math.ceil((max+25)/50.0)*50;

            // Running-profit summary in its own white-space column, deliberately outside the plot.
            int sx=18, sy=116;
            g2.setColor(NAVY); g2.setFont(new Font("SansSerif",Font.BOLD,12)); g2.drawString("RUNNING PROFIT",sx,sy);
            g2.setFont(new Font("SansSerif",Font.BOLD,10)); g2.setColor(new Color(51,66,82));
            g2.drawString("Chosen exits",sx,sy+26);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(64,91,122));
            g2.drawString(moneySigned(chosenRunningProfit),sx,sy+45);
            g2.setFont(new Font("SansSerif",Font.BOLD,10)); g2.setColor(new Color(51,66,82));
            g2.drawString("If always stayed",sx,sy+70);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(100,112,124));
            g2.drawString(stayedTotalComplete?moneySigned(stayedRunningProfit):"N/A",sx,sy+89);
            g2.setFont(new Font("SansSerif",Font.BOLD,10)); g2.setColor(new Color(51,66,82));
            g2.drawString("Walk-away effect",sx,sy+114);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(155,72,67));
            g2.drawString(stayedTotalComplete?moneySigned(chosenRunningProfit-stayedRunningProfit):"N/A",sx,sy+133);
            g2.setFont(new Font("SansSerif",Font.PLAIN,9)); g2.setColor(new Color(105,112,119));
            g2.drawString("Across "+supportedSessions+" playable",sx,sy+150);
            g2.drawString(supportedSessions==1?"session":"sessions",sx,sy+161);
            g2.setColor(new Color(225,230,234)); g2.drawLine(sx,sy+171,left-28,sy+171);
            g2.setFont(new Font("SansSerif",Font.BOLD,9)); g2.setColor(NAVY);
            g2.drawString("RUNNING PROFIT",sx,sy+187);
            g2.drawString("— FROZEN",sx,sy+198);
            g2.setFont(new Font("SansSerif",Font.PLAIN,8)); g2.setColor(new Color(86,96,105));
            g2.drawString("Same "+supportedSessions+" sessions with Frozen",sx,sy+213);
            g2.drawString("policy",sx,sy+224);
            g2.drawString("(30-hand horizon)",sx,sy+235);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(40,82,109));
            g2.drawString(frozenTotalComplete?moneySigned(frozenRunningProfit):"N/A",sx,sy+248);

            // Grid, ticks and a dedicated y-axis title beside the plot rather than over the running-profit block.
            g2.setFont(new Font("SansSerif",Font.PLAIN,9));
            for(int k=0;k<=4;k++){
                double val=max*k/4.0; int y=top+ph-(int)Math.round(val/max*ph);
                g2.setColor(new Color(232,236,239)); g2.drawLine(left,y,left+pw,y);
                g2.setColor(new Color(92,102,110)); String tick="£"+String.format(Locale.ROOT,"%.0f",val);
                g2.drawString(tick,left-14-g2.getFontMetrics().stringWidth(tick),y+3);
            }
            AffineTransform axisTx=g2.getTransform(); g2.rotate(-Math.PI/2);
            g2.setFont(new Font("SansSerif",Font.BOLD,10)); g2.setColor(new Color(62,72,81)); String yAxis="Final bankroll (£)";
            g2.drawString(yAxis,-(top+ph/2+g2.getFontMetrics().stringWidth(yAxis)/2),left-44); g2.setTransform(axisTx);

            int n=Math.max(1,pts.size()),slot=Math.max(1,pw/n),bar=Math.max(9,Math.min(16,Math.max(9,(slot-16)/2)));
            for(int i=0;i<pts.size();i++){
                StatWatchView v=pts.get(i); int cx=left+(int)Math.round((i+0.5)*pw/n), base=top+ph;
                double chosen=v.exitHand>0?v.exitBank:v.chosenBank; int y1=top+ph-(int)Math.round(chosen/max*ph);
                int chosenX=cx-bar-3, stayX=cx+3;
                g2.setColor(new Color(64,91,122)); g2.fillRoundRect(chosenX,y1,bar,Math.max(1,base-y1),5,5);
                if("S14".equalsIgnoreCase(v.label)) drawLiveCasinoInsideBar(g2, chosenX, y1, bar, Math.max(1,base-y1), Color.WHITE);
                
                int y2=Integer.MAX_VALUE;
                if(!Double.isNaN(v.stayBank)){
                    y2=top+ph-(int)Math.round(v.stayBank/max*ph); g2.setColor(new Color(112,124,136)); g2.setStroke(new BasicStroke(1.5f));
                    g2.drawRoundRect(stayX,y2,bar,Math.max(1,base-y2),5,5);
                    if("S14".equalsIgnoreCase(v.label)) drawLiveCasinoInsideBar(g2, stayX, y2, bar, Math.max(1,base-y2), Color.BLACK);
                    
                }

                // v15.10.35: reserve a dedicated 104px evidence-label headroom above the plot so even the tallest STAY label remains fully inside the chart.
                                int chosenCx=chosenX+bar/2, stayCx=stayX+bar/2;
                String chosenHead=isForcedBankrollExit(v)?"BANK H"+v.exitHand:(v.exitHand>0?"WALK H"+v.exitHand:(v.chosenTermination.equals("SOURCE_EXHAUSTED")?"SRC H"+v.chosenHands:"END H"+v.chosenHands));
                String chosenVal="£"+String.format(Locale.ROOT,"%.2f",chosen);
                // v15.10.35: every evidence label remains anchored to the centre of the bar it describes.
                // The label grows vertically upward from the bar top, so it cannot drift toward a neighbour.
                String chosenLabel=chosenHead+"  "+chosenVal;
                drawVerticalLabelAboveBar(g2,chosenLabel,chosenCx,y1-5,new Font("SansSerif",Font.BOLD,9),new Color(45,57,68));

                if(!Double.isNaN(v.stayBank)){
                    String stayHead=(v.stayTermination.equals("SOURCE_EXHAUSTED")?"STAY SRC H"+v.stayHands:"STAY H"+v.stayHands);
                    String stayVal="£"+String.format(Locale.ROOT,"%.2f",v.stayBank);
                    drawVerticalLabelAboveBar(g2,stayHead+"  "+stayVal,stayCx,y2-5,new Font("SansSerif",Font.PLAIN,9),new Color(100,112,124));
                }

                g2.setFont(new Font("SansSerif",Font.BOLD,9)); g2.setColor(new Color(62,72,81)); g2.drawString(v.label,cx-g2.getFontMetrics().stringWidth(v.label)/2,base+18);
                if(v.preambleStart){
                    g2.setFont(new Font("SansSerif",Font.BOLD,8)); g2.setColor(new Color(76,105,131)); g2.drawString("PRE",cx-g2.getFontMetrics().stringWidth("PRE")/2,base+31);
                    // Keep preamble context attached to the session label, never floating in the title/bar-label area.
                    if(v.preambleHands>0){
                        // v15.10.36: deliberately tiny preamble metadata so all five sessions on a page can carry it without crowding.
                        g2.setFont(new Font("SansSerif",Font.PLAIN,6)); g2.setColor(new Color(137,94,18));
                        String pre=v.preambleHands+" pre-hands";
                        g2.drawString(pre,cx-g2.getFontMetrics().stringWidth(pre)/2,base+43);
                    }
                }
            }

            g2.setFont(new Font("SansSerif",Font.BOLD,9)); g2.setColor(new Color(62,72,81)); String xAxis="Session  •  PRE = observed preamble-start replay";
            drawCenteredWithinPanel(g2,xAxis,w,h-31,new Font("SansSerif",Font.BOLD,9),new Color(62,72,81),8);
            String note="Bar labels = final bankroll; STAY keeps the same personality and suppresses only the walk-away decision.";
            drawCenteredWithinPanel(g2,note,w,h-13,new Font("SansSerif",Font.PLAIN,8),new Color(92,102,110),7);
            g2.dispose();
        }
        static void drawCentered(Graphics2D g2,String s,int cx,int y,Font f,Color c){g2.setFont(f);g2.setColor(c);g2.drawString(s,cx-g2.getFontMetrics().stringWidth(s)/2,y);}
        static void drawCenteredWithinPanel(Graphics2D g2,String s,int panelWidth,int y,Font base,Color c,int minSize){
            Font f=base; g2.setFont(f); FontMetrics fm=g2.getFontMetrics(); int available=Math.max(80,panelWidth-24);
            while(fm.stringWidth(s)>available && f.getSize()>minSize){f=f.deriveFont((float)(f.getSize()-1));g2.setFont(f);fm=g2.getFontMetrics();}
            g2.setColor(c); int x=Math.max(12,(panelWidth-fm.stringWidth(s))/2); g2.drawString(s,x,y);
        }
        static void drawVerticalLabel(Graphics2D g2,String s,int x,int y,Font f,Color c){
            AffineTransform old=g2.getTransform(); g2.rotate(-Math.PI/2,x,y); g2.setFont(f); g2.setColor(c); g2.drawString(s,x,y); g2.setTransform(old);
        }
        static void drawVerticalLabelAboveBar(Graphics2D g2,String s,int barCenterX,int barTopY,Font f,Color c){
            AffineTransform old=g2.getTransform();
            g2.setFont(f); g2.setColor(c);
            FontMetrics fm=g2.getFontMetrics();
            int safeY=Math.max(fm.stringWidth(s)+10,barTopY-8);
            // Keep labels vertical, but finish them 8px above the bar top while reserving the full rotated text width
            // so larger labels remain inside the panel instead of clipping at the top edge.
            // Translate to the exact horizontal centre of the bar, then rotate the label upward.
            // A small ascent correction keeps the glyph body centred over the bar rather than to one side.
            g2.translate(barCenterX + fm.getAscent()/2 - 1, safeY);
            g2.rotate(-Math.PI/2);
            g2.drawString(s,0,0);
            g2.setTransform(old);
        }
        static void drawLiveCasinoInsideBar(Graphics2D g2,int x,int y,int width,int height,Color textColor){
            if(height<18 || width<8)return;
            Font f=new Font("SansSerif",Font.BOLD,Math.max(7,Math.min(9,width-4)));
            String s="LIVE CASINO";
            FontMetrics fm=g2.getFontMetrics(f);
            if(fm.stringWidth(s)>height-8){
                s="LC";
                if(fm.stringWidth(s)>height-4)return;
            }
            AffineTransform old=g2.getTransform();
            g2.setFont(f); g2.setColor(textColor);
            int cx=x+width/2; int cy=y+height/2;
            g2.translate(cx+fm.getAscent()/2-1,cy+fm.stringWidth(s)/2);
            g2.rotate(-Math.PI/2);
            g2.drawString(s,0,0);
            g2.setTransform(old);
        }
        static String moneySigned(double v){return (v>=0?"+":"-")+"£"+String.format(Locale.ROOT,"%.2f",Math.abs(v));}
        static String shortExitCause(String s){if(s==null||s.isBlank())return "trend trigger";String q=s.toLowerCase(Locale.ROOT);if(q.contains("drawdown"))return "drawdown/trend";if(q.contains("10-value"))return "visible trend";if(q.contains("loss"))return "loss/trend";return "trend trigger";}
    }

    private static class StatWatchPanel extends JPanel {
        final StatWatchView v; StatWatchPanel(StatWatchView v){this.v=v;setOpaque(true);setBackground(Color.WHITE);setBorder(new LineBorder(new Color(226,231,235)));setToolTipText("Solid path = behaviour actually chosen; dashed continuation = what happened if his exit was ignored.");}
        @Override protected void paintComponent(Graphics g){super.paintComponent(g);Graphics2D g2=(Graphics2D)g.create();g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int w=getWidth(),h=getHeight();int left=48,right=18,top=58,bottom=72;int pw=Math.max(20,w-left-right),ph=Math.max(30,h-top-bottom);
            java.util.List<Double> p=!v.stayPath.isEmpty()?v.stayPath:v.chosenPath;if(p.isEmpty()){g2.setColor(new Color(100,108,116));g2.drawString("No source-supported bankroll path",left,top+20);g2.dispose();return;}double min=Double.POSITIVE_INFINITY,max=Double.NEGATIVE_INFINITY;for(double z:p){min=Math.min(min,z);max=Math.max(max,z);}for(double z:v.chosenPath){min=Math.min(min,z);max=Math.max(max,z);}min=Math.floor((min-10)/10.0)*10;max=Math.ceil((max+10)/10.0)*10;if(max-min<40)max=min+40;
            g2.setFont(new Font("SansSerif",Font.PLAIN,9));for(int k=0;k<=4;k++){double val=min+(max-min)*k/4.0;int y=top+ph-(int)Math.round((val-min)/(max-min)*ph);g2.setColor(new Color(232,236,239));g2.drawLine(left,y,left+pw,y);g2.setColor(new Color(92,102,110));g2.drawString("£"+String.format(Locale.ROOT,"%.0f",val),5,y+3);}int n=Math.max(1,p.size()-1);for(int k=0;k<=3;k++){int hand=(int)Math.round(n*k/3.0);int x=left+(int)Math.round((double)hand/n*pw);g2.setColor(new Color(92,102,110));g2.drawString("H"+hand,x-8,top+ph+16);}
            // stay-at-table path first; after a chosen exit this is the counterfactual continuation.
            if(p.size()>1){Stroke old=g2.getStroke();for(int i=1;i<p.size();i++){boolean post=v.exitHand>0&&i>v.exitHand;g2.setStroke(post?new BasicStroke(2f,BasicStroke.CAP_ROUND,BasicStroke.JOIN_ROUND,0,new float[]{6,5},0):new BasicStroke(2.2f));g2.setColor(post?new Color(125,135,145):NAVY);g2.drawLine(px(i-1,n,left,pw),py(p.get(i-1),min,max,top,ph),px(i,n,left,pw),py(p.get(i),min,max,top,ph));}g2.setStroke(old);}
            // press markers and exit marker
            for(String inf:v.influences){Matcher mm=Pattern.compile("Hand (\\d+) — WAGER: (\\d)x").matcher(inf);if(mm.find()){int hh=Integer.parseInt(mm.group(1));if(hh>=0&&hh<p.size()){int x=px(hh,n,left,pw),y=py(p.get(hh),min,max,top,ph);g2.setColor(new Color(171,117,31));g2.fillOval(x-5,y-5,10,10);g2.setFont(new Font("SansSerif",Font.BOLD,8));g2.drawString(mm.group(2)+"x",x+6,y-5);}}}
            if(v.exitHand>0&&v.exitHand<p.size()){int x=px(v.exitHand,n,left,pw),y=py(v.exitBank,min,max,top,ph);g2.setColor(new Color(177,67,61));g2.fillOval(x-6,y-6,12,12);g2.setFont(new Font("SansSerif",Font.BOLD,9));g2.drawString("EXIT H"+v.exitHand,x+8,y+3);}
            g2.setColor(NAVY);g2.setFont(new Font("SansSerif",Font.BOLD,11));String a=v.exitHand>0?"Chosen exit: H"+v.exitHand+"  £"+String.format(Locale.ROOT,"%.2f",v.exitBank):"Chosen path: "+v.chosenHands+" hands  £"+String.format(Locale.ROOT,"%.2f",v.chosenBank);g2.drawString(v.label+"  •  "+a,left,20);g2.setFont(new Font("SansSerif",Font.PLAIN,10));String b="If he stayed: "+v.stayHands+" hands  £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+"  •  "+v.stayTermination+(v.staySourceComplete?" / source-complete":" / source-bounded");g2.drawString(b,left,37);g2.setColor(new Color(92,102,110));g2.drawString("Gold = evidence-triggered wager press • red = walk-away • dashed = ignored-exit continuation",left,h-18);g2.dispose();}
        static int px(int i,int n,int left,int pw){return left+(int)Math.round((double)i/Math.max(1,n)*pw);}static int py(double v,double min,double max,int top,int ph){return top+ph-(int)Math.round((v-min)/(max-min)*ph);}
    }

    private static class TenBustPoint {
        final String label;
        final double finalBankroll;
        final int busts;
        final boolean positive;
        final boolean available;          // exact 10-card-bust count available
        final boolean closingAvailable;   // closing bankroll independently supported
        final String statusLabel;
        final String statusDetail;
        final boolean hadPreamble;
        final int preambleHands;
        private TenBustPoint(String label, double finalBankroll, int busts, boolean available,
                             boolean closingAvailable, String statusLabel, String statusDetail, boolean hadPreamble, int preambleHands) {
            this.label=label; this.finalBankroll=finalBankroll; this.busts=busts;
            this.available=available; this.closingAvailable=closingAvailable;
            this.positive=closingAvailable && finalBankroll > 100.000001;
            this.statusLabel=statusLabel; this.statusDetail=statusDetail; this.hadPreamble=hadPreamble; this.preambleHands=preambleHands;
        }
        static TenBustPoint measured(String label,double finalBankroll,int busts,boolean hadPreamble,int preambleHands) {
            return new TenBustPoint(label,finalBankroll,busts,true,true,"","",hadPreamble,preambleHands);
        }
        static TenBustPoint historicalTraceMissing(String label,double finalBankroll) {
            return new TenBustPoint(label,finalBankroll,0,false,true,"TRACE N/A",
                    "Historical Casual replay exists; exact hand-card trace required for this count",false,0);
        }
        static TenBustPoint unavailable(String label,String status,boolean hadPreamble,int preambleHands) {
            return new TenBustPoint(label,0,0,false,false,status,
                    "No source-verified complete Casual reconstruction available",hadPreamble,preambleHands);
        }
    }

    private static class TenBustBarPanel extends JPanel {
        private final java.util.List<TenBustPoint> points;
        TenBustBarPanel(java.util.List<TenBustPoint> points) {
            this.points = points;
            setOpaque(true);
            setBackground(Color.WHITE);
            setBorder(new LineBorder(new Color(226, 231, 235)));
            setToolTipText("");
        }
        @Override public String getToolTipText(MouseEvent e) {
            if (points.isEmpty()) return null;
            int left=42,right=14,top=30,bottom=52;
            int pw=Math.max(10,getWidth()-left-right);
            double slot=(double)pw/points.size();
            for(int i=0;i<points.size();i++){
                int cx=left+(int)Math.round((i+0.5)*slot);
                if(Math.abs(e.getX()-cx)<=Math.max(12,slot*0.38)){
                    TenBustPoint p=points.get(i);
                    if (!p.available) return p.label + " • " + p.statusDetail + (p.closingAvailable ? " • session P/L " + signedMoney(p.finalBankroll - 100.0) : "");
                    return p.label + " • 10-card busts " + p.busts + " • session P/L " + signedMoney(p.finalBankroll - 100.0);
                }
            }
            return null;
        }
        @Override protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2=(Graphics2D)g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth(), h=getHeight();
            int left=42,right=14,bottom=52;
            boolean anyPreamble=false; for(TenBustPoint q:points) if(q.hadPreamble){anyPreamble=true;break;}
            int top=anyPreamble?112:30;
            int pw=Math.max(10,w-left-right), ph=Math.max(10,h-top-bottom);
            int yMax=4;
            for(TenBustPoint p:points) if(p.available) yMax=Math.max(yMax,p.busts);

            g2.setFont(new Font("SansSerif",Font.PLAIN,9));
            for(int y=0;y<=yMax;y++){
                int py=top+ph-(int)Math.round((double)y/yMax*ph);
                g2.setColor(new Color(228,233,237)); g2.drawLine(left,py,left+pw,py);
                g2.setColor(new Color(90,100,108)); g2.drawString(String.valueOf(y),left-17,py+3);
            }

            if(!points.isEmpty()){
                double slot=(double)pw/points.size();
                int barW=(int)Math.max(18,Math.min(44,slot*0.55));
                for(int i=0;i<points.size();i++){
                    TenBustPoint p=points.get(i);
                    int cx=left+(int)Math.round((i+0.5)*slot);
                    if(p.hadPreamble){
                        g2.setColor(new Color(137,94,18));
                        g2.setFont(new Font("SansSerif",Font.BOLD,8));
                        String pre=p.preambleHands>0?"("+p.preambleHands+" HANDS)":"(PREAMBLE)";
                        AffineTransform oldTx=g2.getTransform();
                        g2.rotate(-Math.PI/2,cx+3,top-6);
                        g2.drawString(pre,cx+3,top-6);
                        g2.setTransform(oldTx);
                    }
                    int bh=(int)Math.round((double)p.busts/yMax*ph);
                    int by=top+ph-bh;
                    if (p.available) {
                        g2.setColor(p.positive ? new Color(34,125,91) : new Color(190,74,64));
                        g2.fillRoundRect(cx-barW/2,by,barW,Math.max(2,bh),6,6);
                        if ("S14".equalsIgnoreCase(p.label)) {
                            if("S14".equalsIgnoreCase(p.label)) StatWatchBarPanel.drawLiveCasinoInsideBar(g2,cx-barW/2,by,barW,Math.max(2,bh),Color.WHITE);
                        }
                    }
                    g2.setColor(new Color(48,58,66));
                    g2.setFont(new Font("SansSerif",Font.BOLD,p.available ? 9 : 8));
                    String v=p.available ? String.valueOf(p.busts) : p.statusLabel;
                    FontMetrics fm=g2.getFontMetrics();
                    g2.drawString(v,cx-fm.stringWidth(v)/2,p.available ? Math.max(top+10,by-4) : top+ph-8);
                    g2.setFont(new Font("SansSerif",Font.BOLD,9));
                    FontMetrics sf=g2.getFontMetrics();
                    g2.drawString(p.label,cx-sf.stringWidth(p.label)/2,top+ph+15);
                    String closing = p.closingAvailable ? signedMoney(p.finalBankroll - 100.0) : "N/A";
                    g2.setFont(new Font("SansSerif",Font.PLAIN,9));
                    FontMetrics cf=g2.getFontMetrics();
                    g2.drawString(closing,cx-cf.stringWidth(closing)/2,top+ph+29);
                }
            }
            g2.setColor(new Color(90,100,108));
            g2.setFont(new Font("SansSerif",Font.PLAIN,9));
            g2.drawString("Session • profit / loss", left + Math.max(0,pw/2-43), h-6);
            g2.rotate(-Math.PI/2);
            g2.drawString("10-card busts", -(top+ph/2+28), 13);
            g2.rotate(Math.PI/2);
            g2.dispose();
        }
    }


    private static String signedMoney(double value) {
        if (Math.abs(value) < 0.000001) return "£0";
        return (value > 0 ? "+£" : "−£") + money(Math.abs(value));
    }


    private JPanel openingMetricCard(String heading, String value) {
        JPanel p = new JPanel();
        p.setBackground(new Color(238, 244, 249));
        p.setBorder(new CompoundBorder(new LineBorder(new Color(220, 228, 235)), new EmptyBorder(8, 12, 8, 12)));
        p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
        JLabel h = new JLabel(heading, SwingConstants.CENTER);
        h.setFont(new Font("SansSerif", Font.BOLD, 10));
        h.setForeground(new Color(55, 72, 88));
        h.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel v = new JLabel(value, SwingConstants.CENTER);
        v.setFont(new Font("SansSerif", Font.BOLD, 27));
        v.setForeground(NAVY);
        v.setAlignmentX(Component.CENTER_ALIGNMENT);
        p.add(h); p.add(Box.createVerticalStrut(2)); p.add(v);
        p.setPreferredSize(new Dimension(170, 62));
        return p;
    }

    private static int totalHandsDealt(java.util.List<Session> all) {
        int total = 0;
        for (Session s : all) total += s.hands.size();
        return total;
    }

    private static double averageDecisionsPerSession(java.util.List<Session> all) {
        if (all.isEmpty()) return 0.0;
        int decisions = 0;
        for (Session s : all) {
            for (Hand h : s.hands) {
                for (String a : h.actions) {
                    if (a.equals("HIT") || a.equals("STAND") || a.equals("DOUBLE") || a.equals("SPLIT")) decisions++;
                }
            }
        }
        return (double) decisions / all.size();
    }

    private JPanel openingHeaderRow() {
        JPanel r = new JPanel(new GridBagLayout());
        r.setBackground(new Color(232, 237, 242));
        r.setBorder(new MatteBorder(0, 0, 1, 0, new Color(218, 224, 229)));
        addOpeningCell(r, "<html>PLAYER<br>TOTAL</html>", 0, 0.24, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, "<html>DEALER<br>&nbsp;</html>", 1, 0.16, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, "<html>OCCUR-<br>RENCE</html>", 2, 0.25, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, "<html>AVG<br>WAGER</html>", 3, 0.35, SwingConstants.LEFT, Font.BOLD);
        r.setMaximumSize(new Dimension(Integer.MAX_VALUE, 46));
        return r;
    }

    private JPanel openingRow(int rank, OpeningFrequency o) {
        JPanel r = new JPanel(new GridBagLayout());
        r.setBackground(Color.WHITE);
        r.setBorder(new MatteBorder(0, 0, 1, 0, new Color(226, 231, 235)));
        addOpeningCell(r, o.playerState, 0, 0.24, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, o.dealer, 1, 0.16, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, String.valueOf(o.count), 2, 0.25, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, "£" + money(o.averageWager()), 3, 0.35, SwingConstants.LEFT, Font.BOLD);
        r.setMaximumSize(new Dimension(Integer.MAX_VALUE, 38));
        return r;
    }

    private void addOpeningCell(JPanel row, String text, int x, double weight, int align, int style) {
        GridBagConstraints c = new GridBagConstraints();
        c.gridx = x; c.gridy = 0; c.weightx = weight; c.fill = GridBagConstraints.BOTH;
        c.insets = new Insets(0, 0, 0, 0);
        JLabel l = new JLabel(text, align);
        l.setFont(new Font("SansSerif", style, 10));
        l.setForeground(new Color(38, 48, 58));
        l.setBorder(new CompoundBorder(
                x == 0 ? BorderFactory.createEmptyBorder() : new MatteBorder(0, 1, 0, 0, new Color(235, 239, 242)),
                new EmptyBorder(3, 2, 3, 2)));
        row.add(l, c);
    }

    private static java.util.List<OpeningFrequency> openingFrequencies(java.util.List<Session> all) {
        Map<String, OpeningFrequency> map = new LinkedHashMap<>();
        for (Session s : all) {
            for (Hand h : s.hands) {
                if (h.player.size() < 2) continue;
                String playerState = openingPlayerState(h.player.get(0), h.player.get(1));
                String dealer = openingDealerValue(h.dealerUp);
                if (playerState.equals("?") || dealer.equals("?")) continue;
                String k = playerState + "|" + dealer;
                OpeningFrequency f = map.get(k);
                if (f == null) { f = new OpeningFrequency(playerState, dealer); map.put(k, f); }
                f.count++;
                f.wagerTotal += parseMoney(h.wager);
            }
        }
        java.util.List<OpeningFrequency> r = new ArrayList<>();
        for (OpeningFrequency f : map.values()) if (f.count >= 2) r.add(f);
        r.sort((x, y) -> {
            int c = Integer.compare(y.count, x.count);
            if (c != 0) return c;
            c = Double.compare(y.averageWager(), x.averageWager());
            if (c != 0) return c;
            return x.signature().compareTo(y.signature());
        });
        if (r.size() > 20) return new ArrayList<>(r.subList(0, 20));
        return r;
    }

    // Memory Lane deliberately mirrors the early Primary/Holdout player-facing view:
    // opening TOTAL versus dealer upcard. Composition, hard/soft and pair labels are not shown.
    private static String openingPlayerState(String c1, String c2) {
        String a = openingRank(c1), b = openingRank(c2);
        if (a.equals("?") || b.equals("?")) return "?";

        // Memory Lane convention: preserve two aces as the visible opening value 2,
        // rather than folding A+A into the ordinary 12 bucket. This lets "2 vs X"
        // uniquely identify a pair of aces while ordinary 12 totals remain separate.
        if (a.equals("A") && b.equals("A")) return "2";

        int v1 = openingCardValue(a), v2 = openingCardValue(b);
        if (v1 < 0 || v2 < 0) return "?";
        int total = v1 + v2;
        if ((a.equals("A") || b.equals("A")) && total > 21) total -= 10;
        return String.valueOf(total);
    }

    private static String openingDealerValue(String card) {
        String r = openingRank(card);
        if (r.equals("?")) return "?";
        if (r.equals("J") || r.equals("Q") || r.equals("K")) return "10";
        return r;
    }

    private static int openingCardValue(String r) {
        if (r.equals("A")) return 11;
        if (r.equals("10") || r.equals("J") || r.equals("Q") || r.equals("K")) return 10;
        try { return Integer.parseInt(r); } catch (Exception e) { return -1; }
    }

    private static String openingRank(String card) {
        if (card == null) return "?";
        String r = card.replace("♥", "").replace("♦", "").replace("♣", "").replace("♠", "").trim().toUpperCase(Locale.ROOT);
        if (r.startsWith("10")) return "10";
        if (r.startsWith("J")) return "J";
        if (r.startsWith("Q")) return "Q";
        if (r.startsWith("K")) return "K";
        if (r.startsWith("A")) return "A";
        Matcher m = Pattern.compile("([2-9])").matcher(r);
        return m.find() ? m.group(1) : "?";
    }

    private static class OpeningFrequency {
        final String playerState, dealer;
        int count = 0;
        double wagerTotal = 0;
        OpeningFrequency(String playerState, String dealer) { this.playerState = playerState; this.dealer = dealer; }
        double averageWager() { return count == 0 ? 0 : wagerTotal / count; }
        String signature() { return playerState + "|" + dealer; }
    }

    private String liveSessionDisplayTitle(Session s) {
        String title=sessionDisplayTitle(s);
        if(s==null||s.preambleCards<=0||s!=currentSession) return title;
        int formalCommitted=0;
        if(handIndex>=0){
            for(int i=0;i<Math.min(handIndex,s.hands.size());i++) formalCommitted+=recordedCardSequence(s.hands.get(i)).size();
            if(handIndex<s.hands.size()){
                Hand h=s.hands.get(handIndex);
                int cur=0;
                // Count only physically committed/revealed cards at the current playback state.
                // openingStreamRevealCount tracks P1, dealer up/hole placeholder, P2 and opening completion.
                if(openingStreamRevealCount>=1) cur++;
                if(openingStreamRevealCount>=2){ cur++; if(!h.dealerHidden.isEmpty()) cur++; }
                if(openingStreamRevealCount>=3) cur++;
                if(playerRevealIndex>2) cur+=playerRevealIndex-2;
                if(h.split){ cur+=Math.max(0,splitARevealIndex-1)+Math.max(0,splitBRevealIndex-1); }
                // dealerRevealIndex includes hole at index 0; the hole was already counted above.
                if(dealerRevealIndex>1) cur+=dealerRevealIndex-1;
                formalCommitted+=Math.min(cur,recordedCardSequence(h).size());
            }
        }
        int pointer=s.preambleCards+formalCommitted;
        return title.replaceFirst("(?i)(since latest observed shuffle/start:\\s*)\\d+", "$1"+pointer);
    }
    private void refreshDynamicSessionHeader(){ if(currentSession!=null) sessionLabel.setText(liveSessionDisplayTitle(currentSession)+" ("+sessionArchitectureLabel(currentSession)+")"); }

    // v15.10.363-UAT — Session 14 isolated Ultimate AI V2 replay helpers.
    // This is a derived presentation only: original Session 14 evidence is never modified.
    private static Session cloneAsUltimateV2Replay(Session b){
        Session x=new Session();
        x.id=b.id; x.summary=b.summary; x.architecture="ULTIMATE AI V2 — ISOLATED REPLAY";
        x.sessionName=b.sessionName; x.platform=b.platform; x.envCapture=b.envCapture;
        x.envPlatformStart=b.envPlatformStart; x.envSessionBankroll=b.envSessionBankroll;
        x.envScale=b.envScale; x.envPreambleStatus=b.envPreambleStatus; x.envShuffleStart=b.envShuffleStart;
        x.envPlate=b.envPlate; x.publicationNumber=b.publicationNumber; x.offset=b.offset;
        x.actualStart=b.actualStart; x.actualEnd=b.actualEnd; x.hadPreamble=b.hadPreamble;
        x.preambleCards=b.preambleCards; x.realCasino=b.realCasino; x.hands=b.hands;
        x.ultimateV2Replay=true; return x;
    }
    private static int v2CardValue(String c){
        String k=key(c); if("10".equals(k))return 10; if("A".equals(k))return 11;
        try{return Integer.parseInt(k);}catch(Exception e){return -1;}
    }
    private static java.util.List<Integer> v2PriorHoles(Session s,int handIndex){
        java.util.List<Integer> z=new ArrayList<>();
        for(int i=0;i<handIndex && i<s.hands.size();i++){
            Hand h=s.hands.get(i); if(!h.dealerHidden.isEmpty()) {int v=v2CardValue(h.dealerHidden.get(0)); if(v>0)z.add(v);}
        } return z;
    }
    private static String v2Assessment(Session s,int hi){
        java.util.List<Integer> holes=v2PriorHoles(s,hi); int n=holes.size(),tens=0,run=0;
        for(Integer v:holes)if(v==10)tens++;
        for(int i=n-1;i>=0;i--){if(holes.get(i)==10)run++;else break;}
        if(n==0)return "INSUFFICIENT";
        double pct=100.0*tens/n; String concentration=pct>=75?"HIGH":pct>=50?"MODERATE":"LOW";
        if(n<2)return "EARLY";
        if(n<3)return (concentration.equals("HIGH")&&run>=2)?"EMERGING":"LIMITED";
        if(n<6)return (concentration.equals("HIGH")&&run>=3)?"DEVELOPING":"MIXED";
        return (concentration.equals("HIGH")&&run>=4)?"SUSTAINED":"MIXED";
    }
    private static String v2Watch(Session s,int hi){
        java.util.List<Integer> holes=v2PriorHoles(s,hi); int run=0;
        for(int i=holes.size()-1;i>=0;i--){if(holes.get(i)==10)run++;else break;}
        String a=v2Assessment(s,hi);
        if("SUSTAINED".equals(a) || ("DEVELOPING".equals(a)&&run>=4))return "ELEVATED_WATCH";
        if("DEVELOPING".equals(a)||"EMERGING".equals(a))return "WATCH";
        return "NONE";
    }
    private static int[] v2SignalTotals(Session s,int throughExclusive){
        int n=0,c=0,b=0,w=0,wc=0,wb=0,e=0,ec=0,eb=0;
        for(int i=0;i<throughExclusive && i<s.hands.size();i++){
            String watch=v2Watch(s,i); Hand h=s.hands.get(i); if(h.dealerHidden.isEmpty())continue;
            boolean cont=v2CardValue(h.dealerHidden.get(0))==10;
            if("WATCH".equals(watch)){n++;w++;if(cont){c++;wc++;}else{b++;wb++;}}
            else if("ELEVATED_WATCH".equals(watch)){n++;e++;if(cont){c++;ec++;}else{b++;eb++;}}
        } return new int[]{n,c,b,w,wc,wb,e,ec,eb};
    }
    private static String v2Calibration(int n,int c,int b){
        String status=n<3?"INSUFFICIENT":n<6?"EARLY":n<12?"ACCUMULATING":"TESTABLE";
        String bal=n<3?"TOO_EARLY":(c>0&&b>0?"MIXED":"ONE_SIDED");
        return status+" • "+bal;
    }
    private String v2PreText(Session s,int hi){
        java.util.List<Integer> holes=v2PriorHoles(s,hi); int tens=0,run=0; for(int v:holes)if(v==10)tens++;
        for(int i=holes.size()-1;i>=0;i--){if(holes.get(i)==10)run++;else break;}
        int[] q=v2SignalTotals(s,hi); String watch=v2Watch(s,hi),a=v2Assessment(s,hi);
        return "<html><div style='text-align:center'><b>ISOLATED SESSION EVIDENCE</b> • V2 OBSERVATION ONLY • NO DECISION OR WAGER INFLUENCE<br>"+
            "<b>10E</b> &nbsp; prior holes "+holes.size()+" &nbsp; • &nbsp; ten-value "+tens+"/"+holes.size()+" &nbsp; • &nbsp; trailing run "+run+
            " &nbsp;&nbsp; | &nbsp;&nbsp; <b>10F</b> &nbsp; "+a+" &nbsp;&nbsp; | &nbsp;&nbsp; <b>10G</b> &nbsp; WATCH "+watch+"<br>"+
            "<b>10H</b> &nbsp; signal events "+q[0]+" &nbsp; (continued "+q[1]+" / broke "+q[2]+") &nbsp;&nbsp; | &nbsp;&nbsp; <b>10I</b> &nbsp; "+v2Calibration(q[0],q[1],q[2])+
            " • current hole BLOCKED • predictive value UNESTABLISHED</div></html>";
    }
    private String v2PostText(Session s,int hi){
        Hand h=s.hands.get(hi); String watch=v2Watch(s,hi); boolean known=!h.dealerHidden.isEmpty();
        boolean cont=known&&v2CardValue(h.dealerHidden.get(0))==10; int[] q=v2SignalTotals(s,hi+1);
        return "<html><div style='text-align:center'><b>ULTIMATE AI V2 — AFTER REVEAL</b> • committed watch "+watch+
            " • structure "+(known?(cont?"CONTINUED":"BROKE"):"UNSCORABLE")+" • retrospective rewrite NONE<br>"+
            "<b>10H</b> &nbsp; signal events "+q[0]+" &nbsp; (continued "+q[1]+" / broke "+q[2]+") &nbsp;&nbsp; | &nbsp;&nbsp; <b>10I</b> &nbsp; "+v2Calibration(q[0],q[1],q[2])+
            " • deck/shuffle model UNKNOWN • live influence NONE</div></html>";
    }

    private void selectSession(Session s) {
        currentSession=s;
        // v15.10.83-UAT: Hybrid is only a valid comparison target for an observed
        // Hybrid session with retained formal-start evidence.  When the operator
        // moves back to an earlier/non-Hybrid session, restore the established
        // Frozen-vs-Casual path automatically instead of leaving COMPARE greyed out.
        refreshCompareTargets(s);
        updateCompareAvailability();
        timer.stop(); handIndex=-1; stage=0;
        sessionLabel.setText(liveSessionDisplayTitle(s) + " (" + sessionArchitectureLabel(s) + ")");
        clearTable();
        handLabel.setText("HAND -- / " + s.hands.size());
        bankrollLabel.setText(s.summary.isBlank()?"Historical replay":s.summary);
        chipDisplay.setValues(s.standalonePreamble?100.0:0, 0);
        dealerTotalLabel.setText("TOTAL --"); playerTotalLabel.setText("TOTAL --");
        wagerLabel.setText("Press PLAY");
        actionLabel.setFont(new Font("SansSerif", Font.BOLD, 16));
        String uatPurpose=engineeringValidationPurpose(s);
        if(uatPurpose!=null){
            // Phase 10D Replay UX: engineering/UAT sessions remain replayable evidence, but
            // the selection screen explains why they exist before ordinary hand playback.
            // v15.10.344-UAT: keep the engineering-purpose copy inside the centre felt.
            // A deliberately bounded HTML width forces wrapping before the east context panel.
            // v15.10.348-UAT: left-align the engineering evidence block so the available
            // centre width is used efficiently. Underlined section headers make the purpose,
            // status and action scannable; wrapping is retained inside the existing panel.
            actionLabel.setFont(new Font("SansSerif", Font.PLAIN, 15));
            actionLabel.setHorizontalAlignment(SwingConstants.LEFT);
            // v15.10.350-UAT: Engineering selection is a text-first audit page. Reclaim the
            // otherwise-empty dealer/player card lanes so wrapped Purpose / Status / Action
            // copy can flow without colliding with PLAYER/TOTAL or the bankroll footer.
            // Normal hand playback restores the established 132px card lanes in clearTable().
            actionLabel.setVerticalAlignment(SwingConstants.TOP);
            int purposeLines = Math.max(1, (uatPurpose.length() + 67) / 68);
            int actionHeight = Math.min(220, 160 + Math.max(0, purposeLines - 1) * 22);
            actionLabel.setPreferredSize(new Dimension(620,actionHeight));
            actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,actionHeight));
            dealerCards.setPreferredSize(new Dimension(1000,72));
            dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,72));
            playerCards.setPreferredSize(new Dimension(1000,44));
            playerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,44));
            actionLabel.setText("<html><div style='text-align:left;width:560px'>"+
                    "<b><u>ENGINEERING VALIDATION SESSION — "+html(engineeringPhase(s))+"</u></b><br>"+
                    "<b><u>Purpose</u></b><br>"+html(uatPurpose)+"<br>"+
                    "<b><u>Status</u></b><br>Retained UAT / software-validation evidence — not observed gameplay and excluded from observational research statistics.<br>"+
                    "<b><u>Action</u></b><br>Press PLAY to inspect the recorded hand evidence."+
                    "</div></html>");
        }else{
            actionLabel.setVerticalAlignment(SwingConstants.CENTER);
            dealerCards.setPreferredSize(new Dimension(1000,132));
            dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
            playerCards.setPreferredSize(new Dimension(1000,132));
            playerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
            actionLabel.setHorizontalAlignment(SwingConstants.CENTER);
            actionLabel.setPreferredSize(new Dimension(1000,28));
            actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
            actionLabel.setText("READY • RECORDED HISTORICAL EVIDENCE");
        }
        chipDisplay.setEngineeringSelection(uatPurpose!=null);
        journeyStrip.showHistory(null, -1); controllerCounterfactualStrip.showState(s,0); shoePanel.reset(); previousPanel.clear(s);
    }

    private String engineeringPhase(Session s){
        String n=s==null||s.sessionName==null?"":s.sessionName.toUpperCase(Locale.ROOT);
        Matcher m=Pattern.compile("PHASE\\s*(10[A-Z]?)").matcher(n.replace('_',' '));
        if(m.find()) return "PHASE "+m.group(1);
        return "UAT";
    }

    private String engineeringValidationPurpose(Session s){
        if(s==null||s.sessionName==null)return null;
        String n=s.sessionName.toUpperCase(Locale.ROOT);
        if(!isEngineeringValidationSession(s))return null;
        if(n.contains("CARD_CHECK_HIT_CHRONOLOGY"))return "Validate that an intentional player HIT against the Frozen STAND reference is retained in physical Card Check chronology and replay evidence.";
        if(n.contains("ROLLBACK_MODE_STATE"))return "Validate that rejecting/re-entering a hand restores the original Frozen mode state without leaking Hybrid state.";
        if(n.contains("HIT_COMPLETION_PRESENTATION"))return "Validate that ordinary HIT cards remain visible through live RESULT/COMPLETE presentation without changing authoritative settlement evidence.";
        if(n.contains("SPLIT_RESULT_PRESENTATION"))return "Validate that split child hands remain separately represented at completion instead of collapsing into a false single-hand total.";
        if(n.contains("BUST_DEALER_PRESENTATION"))return "Validate busted-player dealer-transition presentation while preserving the recorded physical card evidence.";
        if(n.contains("LIVE_TRACE")&&n.contains("PHASE10D"))return "Validate Phase 10D controlled-evidence live trace surfacing while Frozen remains the live wager/action authority.";
        if(n.contains("V2_FRESH_SESSION_BENCHMARK"))return "Exercise Ultimate AI V2 lifecycle behaviour across a controlled fresh-session benchmark for Phase 10D evidence review.";
        if(n.contains("ULTIMATE AI V2 CONTROLLED TEST") || (n.contains("ULTIMATE AI V2")&&n.contains("CONTROLLED TEST")))return "Stress-test retained Card Check chronology and Ultimate AI V2 exit/watch/re-entry lifecycle across long HIT and extreme SPLIT hands.";
        if(n.contains("PHASE10D"))return "Retained Phase 10D engineering validation session; inspect the recorded evidence to review the behaviour under test.";
        if(n.contains("PHASE10E")&&n.contains("LIFECYCLE"))return "Regression-test the Ultimate AI V2 exit, watch, candidate, withdrawal and re-entry lifecycle after Phase 10D repairs.";
        return null;
    }

    private void clearTable() {
        dealerCards.removeAll(); playerCards.removeAll(); splitCards.removeAll(); splitCards.setVisible(false);
        // v15.10.194: restore the normal full-height dealer lane at every new hand.
        // Split playback temporarily uses a shorter fully-rendered dealer footprint only to
        // reclaim lower felt for the two child verdicts.
        dealerCards.setPreferredSize(new Dimension(1000,132));
        dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
        // Restore the established normal-hand footprints after any split hand.
        dealerCards.setPreferredSize(new Dimension(1000,132));
        dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
        playerCards.setPreferredSize(new Dimension(1000,132));
        playerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
        chipDisplay.setVisible(true);
        playerCards.setVisible(true); playerOutcomeLabel.setVisible(true); playerOutcomeLabel.setText(" ");
        dealerTotalLabel.setText("TOTAL --"); playerTotalLabel.setText("TOTAL --");
        resultLabel.setText(" "); dealerCards.revalidate(); playerCards.revalidate(); repaint();
    }

    private void previousHand() {
        if(currentSession==null || currentSession.hands.isEmpty()) return;
        timer.stop();
        if(handIndex<=0){ handIndex=-1; nextHand(); return; }
        handIndex=Math.max(-1,handIndex-2);
        nextHand();
    }

    private void nextHand() {
        if(currentSession==null) return;
        handIndex++;
        if(handIndex>=currentSession.hands.size()) {
            timer.stop(); handIndex=currentSession.hands.size()-1;
            actionLabel.setText("SESSION REPLAY COMPLETE");
            // v15.10.376-UAT: completion is one clean state, not three overlapping verdict layers.
            resultLabel.setText(" ");
            playerOutcomeLabel.setText(" ");
            journeyStrip.showHistory(currentSession, currentSession.hands.size());
            return;
        }
        stage=0; playerRevealIndex=0; dealerRevealIndex=0; splitARevealIndex=0; splitBRevealIndex=0; openingStreamRevealCount=0; clearTable();
        chipDisplay.setEngineeringSelection(false);
        actionLabel.setVerticalAlignment(SwingConstants.CENTER);
        // v15.10.318-UAT P004: ready-state uses a slightly smaller full-text font; restore normal action font at hand boundary.
        actionLabel.setFont(new Font("SansSerif", Font.BOLD, 19));
        // v15.10.206: restore normal one-line action geometry at every hand boundary.
        actionLabel.setPreferredSize(new Dimension(1000,28));
        actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
        Hand h=currentSession.hands.get(handIndex);
        refreshDynamicSessionHeader();
        boolean quarantinedPlayback=isSemanticQuarantined(currentSession);
        handLabel.setText((quarantinedPlayback?"QUARANTINED • ":"")+String.format("HAND %02d / %02d", h.number,currentSession.hands.size()));
        bankrollLabel.setText("BANKROLL £"+money(h.researchStart()));
        wagerLabel.setText((quarantinedPlayback?"FORENSIC REPLAY ONLY • ":"")+"Wager £"+h.wager+(h.committed.equals(h.wager)?"":"  •  committed £"+h.committed));
        chipDisplay.setValues(Math.max(0, h.researchStart() - parseMoney(h.wager)), parseMoney(h.wager));
        journeyStrip.showHistory(currentSession, handIndex);
        controllerCounterfactualStrip.showState(currentSession,h.number);
        previousPanel.showMatches(sessions,currentSession,handIndex,h,false);
        shoePanel.setSource(currentSession);
        shoePanel.setPolicyStatus(h); if(currentSession!=null&&currentSession.standalonePreamble)shoePanel.setObservedPersonalPolicy(h);
        shoePanel.setStreak(currentSession, handIndex);
        shoePanel.setReplayContext(currentSession, handIndex);
        if(h.shuffleBefore){shoePanel.showShuffle(h.shufflePreviousCards,h.number);actionLabel.setText("SHUFFLE RECORDED • BEFORE H"+h.number);stage=-8;} else {shoePanel.startHand(h.number);actionLabel.setText("Dealing…");}
        // v15.10.189: preserve one clean hand-start frame so the bankroll/on-table
        // chips are fully visible before any card is dealt. The first timer tick begins
        // dealing and removes the chip lane. Presentation timing only.
    }

    private void tick() {
        if(currentSession==null || handIndex<0) return;
        Hand h=currentSession.hands.get(handIndex);
        refreshDynamicSessionHeader();
        if(stage<0){shoePanel.advanceShuffle(); if(stage==-1){shoePanel.finishShuffle();actionLabel.setText("Shuffle complete — dealing from recorded shoe…");stage=0;} else {stage++;} return;}
        if(stage==0) { // player first card / dealing begins
            // v15.10.189: chips belong only to the pre-deal hand-start frame. Collapse
            // their lane before the first card appears, reclaiming the full lower felt
            // for cards, action updates and the final verdict.
            chipDisplay.setVisible(false);
            chipDisplay.repaint();
            if(!h.player.isEmpty()) addCard(playerCards,h.player.get(0),false); playerRevealIndex=1; openingStreamRevealCount=1; refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,false); stage=1; return;
        }
        if(stage==1) { // dealer upcard + facedown hole-card placeholder
            addCard(dealerCards,h.dealerUp,false);
            if(!h.dealerHidden.isEmpty()) addCard(dealerCards,"HOLE",true);
            dealerRevealIndex=0; openingStreamRevealCount=2; refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,false); stage=2; return;
        }
        if(stage==2) { // player second card
            if(playerRevealIndex<h.player.size()) { addCard(playerCards,h.player.get(playerRevealIndex++),false); openingStreamRevealCount=3; refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,false); stage=25; }
            else stage=4; return;
        }
        if(stage==25) { // complete the four-card opening stream row in physical capture order
            openingStreamRevealCount=4;
            previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
            if(currentSession.ultimateV2Replay){ actionLabel.setVerticalAlignment(SwingConstants.TOP); actionLabel.setFont(new Font("SansSerif",Font.PLAIN,14)); actionLabel.setPreferredSize(new Dimension(1000,72)); actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,72)); actionLabel.setText(v2PreText(currentSession,handIndex)); }
            stage=3; return;
        }
        if(stage==3) { // remaining player draws
            if(playerRevealIndex<h.player.size()) {
                String revealAction = actionForReveal(h, playerRevealIndex);
                actionLabel.setText(revealAction);
                if(revealAction.toUpperCase().contains("DOUBLE")) {
                    double committed = parseMoney(h.committed);
                    chipDisplay.setValues(Math.max(0, h.researchStart() - committed), committed);
                }
                addCard(playerCards,h.player.get(playerRevealIndex++),false); refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,true); return;
            }
            stage=4; return;
        }
        if(stage==4) {
            if(h.split) {
                // First show the original pair physically separating into two hands.
                splitARevealIndex = Math.min(1, h.splitA.size());
                splitBRevealIndex = Math.min(1, h.splitB.size());
                showSplit(h, splitARevealIndex, splitBRevealIndex);
                double committed = parseMoney(h.committed);
                chipDisplay.setValues(Math.max(0, h.researchStart() - committed), committed);
                actionLabel.setText("<html><div style='text-align:center'>PLAYER SPLITS — original pair separates<br>into SPLIT A and SPLIT B</div></html>");
                actionLabel.setPreferredSize(new Dimension(1000,46));
                actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,46));
                stage=40;
            } else {
                actionLabel.setText("Player action complete — dealer resolves");
                stage=5;
            }
            return;
        }
        if(stage==40) {
            // Deal/reveal the recorded child cards one at a time so the split is visually played out.
            if(splitARevealIndex < h.splitA.size()) {
                String c = h.splitA.get(splitARevealIndex++);
                showSplit(h, splitARevealIndex, splitBRevealIndex);
                previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                actionLabel.setPreferredSize(new Dimension(1000,28));
                actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
                actionLabel.setText("SPLIT A receives " + c);
                return;
            }
            if(splitBRevealIndex < h.splitB.size()) {
                String c = h.splitB.get(splitBRevealIndex++);
                showSplit(h, splitARevealIndex, splitBRevealIndex);
                previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                actionLabel.setPreferredSize(new Dimension(1000,28));
                actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
                actionLabel.setText("SPLIT B receives " + c);
                return;
            }
            actionLabel.setText("Split hands complete — dealer resolves");
            stage=5;
            return;
        }
        if(stage==5) { // reveal dealer hole card, then dealer draws
            if(dealerRevealIndex<h.dealerHidden.size()) {
                String c=h.dealerHidden.get(dealerRevealIndex);
                if(dealerRevealIndex==0 && dealerCards.getComponentCount()>=2) {
                    dealerCards.remove(1);
                    dealerCards.add(new CardPanel(c,false,true),1);
                    adaptReplayCardRow(dealerCards); dealerCards.revalidate(); dealerCards.repaint();
                    dealerRevealIndex++; refreshTotals(h);
                    previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                    if(currentSession.ultimateV2Replay){ actionLabel.setVerticalAlignment(SwingConstants.TOP); actionLabel.setFont(new Font("SansSerif",Font.PLAIN,14)); actionLabel.setPreferredSize(new Dimension(1000,78)); actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,78)); actionLabel.setText(v2PostText(currentSession,handIndex)); } else actionLabel.setText("Dealer reveals hole card");
                    // Once the player has busted, the dealer does not take any further draws.
                    // Historical replay may retain additional dealer-card text from source summaries,
                    // but those cards must not be animated as dealer hits after a player bust.
                    if (h.playerBust || h.natural) stage=6;
                    return;
                }
                if (h.playerBust || h.natural) { stage=6; return; }
                boolean invalidPostTerminal=isSemanticQuarantined(currentSession) && dealerTerminalBeforeHiddenIndex(h,dealerRevealIndex);
                dealerRevealIndex++;
                if(invalidPostTerminal) addCard(dealerCards,c,false,false,true); else addCard(dealerCards,c,false);
                refreshTotals(h);
                previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                if(invalidPostTerminal){
                    actionLabel.setText("<html><div style='text-align:center'>⚠ INVALID POST-TERMINAL EVIDENCE<br><span style='font-size:80%'>retained for forensic inspection — not valid gameplay</span></div></html>");
                    actionLabel.setPreferredSize(new Dimension(1000,46));
                    actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,46));
                } else actionLabel.setText("Dealer draws"); return;
            }
            stage=6; return;
        }
        if(stage==6) {
            if(h.evidenceGap) actionLabel.setText("⚠ Evidence gap retained — unknown/ambiguous card not invented");
            if(h.split) {
                String a=friendlyOutcome(h.splitAResult), b=friendlyOutcome(h.splitBResult);
                boolean childKnown=!a.isBlank() && !b.isBlank();
                // Use the retained recorded bankroll transition itself for the standalone
                // round verdict. This deliberately overrides legacy round W/L flags on split
                // hands such as Session 10 H22 where one child wins, one loses and bankroll
                // is unchanged. The displayed bankroll uses the same retained endpoints.
                double net=parseMoney(h.endBank)-parseMoney(h.startBank);
                String overall;
                boolean banksKnown=h.startBank!=null && h.endBank!=null &&
                    h.startBank.matches(".*\\d.*") && h.endBank.matches(".*\\d.*");
                if(banksKnown) overall=net>0.004?"WIN":net<-0.004?"LOSS":"PUSH";
                else overall=friendlyOutcome(h.result);
                // Refresh the split panels at settlement so the child verdicts appear only
                // now, beneath their respective child hands.
                showSplit(h, h.splitA.size(), h.splitB.size());
                playerOutcomeLabel.setText(" ");
                String check=displaySettlementCheck(h);
                String base=overall.isBlank()?"SPLIT HAND COMPLETE":"OVERALL HAND — "+overall;
                // v15.10.206: standalone Replay has less horizontal room than the
                // juxtaposed panes once the right-hand analysis panel is visible. Keep
                // the two independently-derived verdicts explicit instead of allowing
                // Swing to ellipsize the settlement-integrity text.
                if(check.isBlank()) {
                    actionLabel.setText(base);
                    actionLabel.setPreferredSize(new Dimension(1000,28));
                    actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,28));
                } else {
                    actionLabel.setText("<html><div style='text-align:center'>"+base+"<br><span style='font-size:85%'>"+check+"</span></div></html>");
                    actionLabel.setPreferredSize(new Dimension(1000,46));
                    actionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,46));
                }
            }
            else {
                playerOutcomeLabel.setText(handOutcomeText(h));
                String check=displaySettlementCheck(h);
                // UAT settlement-presentation repair: ordinary historical settlement differences
                // remain available as retained evidence, but they must not occupy or obscure the
                // central Dealer -> Player card surface. Only a true unresolved-card verification
                // failure remains an in-play warning. Recorded-vs-calculated settlement differences
                // are secondary diagnostics, exposed as a tooltip on the compact outcome label.
                if(check.startsWith("⚠ UNRESOLVED CARD")) {
                    actionLabel.setText(check);
                } else if(!check.isBlank()) {
                    playerOutcomeLabel.setToolTipText(check);
                }
            }
            // Split hands already carry their two child verdicts beneath the child cards.
            // Ordinary hands use the bottom verdict lane. Restore the established compact
            // settlement agreement/disagreement indicator beneath the monetary result; never
            // put the long diagnostic calculation back across the Dealer -> Player surface.
            if(h.split || currentSession.ultimateV2Replay) {
                // Ultimate AI V2 derived replays use the compact outcome immediately beneath
                // the player cards. Suppress the inherited oversized bottom verdict/settlement
                // overlay so the research trace and variable-length card rows keep clear space.
                // Presentation only: recorded result, settlement, decision and wager are unchanged.
                resultLabel.setText(" ");
            } else {
                String base=resultText(h);
                String check=displaySettlementCheck(h);
                String compact="";
                if(check.startsWith("✓")) compact="✓ DISPLAY SETTLEMENT CONSISTENT";
                else if(check.startsWith("⚠ RECORDED SETTLEMENT DIFFERENCE")) compact="⚠ DISPLAY / RECORDED SETTLEMENT DIFFER";
                if(isSemanticQuarantined(currentSession) && hasDealerPostTerminalEvidence(h))
                    resultLabel.setText("<html><div style='text-align:center'><span style='color:#ffb3ad'>⚠ QUARANTINED RETAINED OUTCOME — NOT VALID GAMEPLAY</span><br>"+base+"</div></html>");
                else resultLabel.setText(" ");
            }
            bankrollLabel.setText("£"+money(h.researchStart())+"  →  £"+money(h.researchEnd()));
            chipDisplay.setValues(h.researchEnd(), 0);
            stage=7; return;
        }
        if(stage==7) nextHand();
    }

    private static String displaySettlementCheck(Hand h) {
        if(h==null || h.startBank==null || h.endBank==null || !h.startBank.matches(".*\\d.*") || !h.endBank.matches(".*\\d.*")) return "";
        // v15.10.209 final provenance gate: a completed hand may not receive a green
        // settlement-consistent tick while any displayed card has an unresolved RANK. A known
        // rank with unknown suit (4U/4?) is still settlement-accountable because suit is irrelevant.
        if(hasUnresolvedRank(h)) return "⚠ UNRESOLVED CARD — DISPLAY VERIFICATION FAILED";
        double recorded=parseMoney(h.endBank)-parseMoney(h.startBank), implied=0.0;
        if(h.split){
            if(h.splitAResult.isBlank() || h.splitBResult.isBlank() || Double.isNaN(h.splitAStake) || Double.isNaN(h.splitBStake)) return "";
            implied += settlementDelta(h.splitAResult,h.splitAStake,false);
            implied += settlementDelta(h.splitBResult,h.splitBStake,false);
        } else {
            String r=friendlyOutcome(h.result); if(r.isBlank()) return "";
            double stake=parseMoney(h.committed); if(stake<=0) return "";
            implied=settlementDelta(r,stake,h.natural && "WIN".equals(r));
        }
        return Math.abs(implied-recorded)<=0.011 ? "✓ DISPLAY SETTLEMENT CONSISTENT" :
            "⚠ RECORDED SETTLEMENT DIFFERENCE — calculated 3:2/display settlement " + signedMoney(implied) + " vs recorded bankroll movement " + signedMoney(recorded);
    }
    private static boolean hasUnresolvedRank(Hand h){
        if(h==null)return false;
        List<String> all=new ArrayList<>(); all.addAll(h.player); all.addAll(h.splitA); all.addAll(h.splitB); all.addAll(h.dealerHidden); if(h.dealerUp!=null)all.add(h.dealerUp);
        for(String raw:all){
            if(raw==null)return true; String x=raw.trim().toUpperCase(Locale.ROOT);
            if(x.isEmpty()||x.equals("?")||x.equals("+")||x.contains("UNKNOWN"))return true;
            // Known-rank unresolved-suit forms such as 4U or 6? are accounted evidence.
            if(x.matches("(A|[2-9]|10|J|Q|K)[U?]"))continue;
            if(cc(x)==null)return true;
        }
        return false;
    }

    private static double settlementDelta(String result,double stake,boolean blackjack){
        String r=friendlyOutcome(result); if("WIN".equals(r)) return stake*(blackjack?1.5:1.0); if("LOSS".equals(r)) return -stake; return 0.0;
    }
    private static String directionMarker(Session s, Hand h) {
        String arch = s==null || s.architecture==null ? "" : s.architecture.toUpperCase(Locale.ROOT);
        if (arch.contains("PERSONAL")) return "P";
        if (arch.contains("FROZEN") && !arch.contains("HYBRID")) return "F";
        if (arch.contains("HYBRID")) {
            // A Hybrid hand is marked P if either its wager or any supported card decision
            // departed from Frozen. Otherwise it is marked F.
            if (Math.abs(parseMoney(h.wager)-parseMoney(h.refWager)) >= 0.001) return "P";
            if (Boolean.FALSE.equals(h.frozenFollowed)) return "P";
            return "F";
        }
        return h.frozenFollowed==null ? "?" : (h.frozenFollowed ? "F" : "P");
    }

    private static String recentPlayerExperienceOutcome(Hand h) {
        // v15.10.167 Patch 1: Recent Player Experience must publish the
        // completed settlement result, not a stale/default parser state.
        // Settlement bankroll movement is authoritative when available.
        if (h != null) {
            double before = parseMoney(h.startBank);
            double after = parseMoney(h.endBank);
            if (!Double.isNaN(before) && !Double.isNaN(after)) {
                double d = after - before;
                if (d > 0.0001) return "W";
                if (d < -0.0001) return "L";
                return "P";
            }
        }
        return friendlyOutcome(h == null ? "" : h.result);
    }

    private static String recentPlayerExperience(Session s, int currentIndex) {
        if (s==null || currentIndex<=0) return "RECENT PLAYER EXPERIENCE  •  no previous hands";
        int from=Math.max(0,currentIndex-5);
        StringBuilder b=new StringBuilder("RECENT PLAYER EXPERIENCE  •  ");
        for(int i=from;i<currentIndex;i++){
            Hand h=s.hands.get(i);
            if(i>from)b.append("   |   ");
            b.append("H").append(h.number).append(" ")
             .append(recentPlayerExperienceOutcome(h)).append(" (£").append(money(parseMoney(h.wager))).append(") (")
             .append(directionMarker(s,h)).append(")");
        }
        return b.toString();
    }

    private static String playerCardsTakenLabel(Hand h) {
        if (h==null || h.split || !"WIN".equals(friendlyOutcome(h.result))) return "—";
        int n = h.player==null ? 0 : h.player.size();
        return n==3 ? "3" : (n>=4 ? "4+" : "—");
    }

    // v15.10.197-UAT: compact semantic controller identity used on the main Historical Replay.
    static class ControllerIdentityGlyph extends JComponent {
        final String kind;
        ControllerIdentityGlyph(String kind){this.kind=kind;setPreferredSize(new Dimension(58,58));setMinimumSize(getPreferredSize());setMaximumSize(getPreferredSize());setToolTipText(kind+" controller identity");}
        @Override protected void paintComponent(Graphics q){super.paintComponent(q);Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int cx=getWidth()/2,cy=getHeight()/2;g.setColor(new Color(13,64,174));g.setStroke(new BasicStroke(3f,BasicStroke.CAP_ROUND,BasicStroke.JOIN_ROUND));
            if("FROZEN".equals(kind)){for(int a=0;a<3;a++){double z=a*Math.PI/3;int dx=(int)(20*Math.cos(z)),dy=(int)(20*Math.sin(z));g.drawLine(cx-dx,cy-dy,cx+dx,cy+dy);for(int sign:new int[]{-1,1}){int ex=cx+sign*dx,ey=cy+sign*dy;double ang=z+(sign<0?Math.PI:0);for(double off:new double[]{-.55,.55}){int bx=(int)(8*Math.cos(ang+Math.PI+off)),by=(int)(8*Math.sin(ang+Math.PI+off));g.drawLine(ex,ey,ex+bx,ey+by);}}}}
            else if("CASUAL".equals(kind)){g.fillOval(cx-8,cy-20,16,16);g.fillOval(cx-25,cy-12,14,14);g.fillOval(cx+11,cy-12,14,14);g.fillRoundRect(cx-15,cy-1,30,22,11,11);g.fillRoundRect(cx-31,cy+2,19,17,8,8);g.fillRoundRect(cx+12,cy+2,19,17,8,8);}
            else {g.fillOval(cx-9,cy-21,18,18);g.fillRoundRect(cx-16,cy-1,32,25,11,11);g.setColor(Color.WHITE);g.fillOval(cx+6,cy+5,18,18);g.setColor(new Color(13,64,174));g.setStroke(new BasicStroke(3f));g.drawOval(cx+6,cy+5,18,18);g.drawLine(cx+15,cy+9,cx+15,cy+19);g.drawLine(cx+10,cy+14,cx+20,cy+14);}
            g.dispose();}
    }
    private static String controllerIdentityKind(Session s){String a=s==null||s.architecture==null?"":s.architecture.toUpperCase(Locale.ROOT);if(a.contains("FROZEN")&&!a.contains("HYBRID"))return "FROZEN";if(a.contains("CASUAL"))return "CASUAL";return "HYBRID";}

    // v15.10.281-FREEZE: persistent Replay controller-slot contract. The normal
    // right-hand evidence area is reserved for these two active behavioural/AI
    // observers only. Fixed-Policy Casual remains a fully supported research
    // target through Compare, but must not displace either persistent slot.
    private static final String PERSISTENT_CONTROLLER_SLOT_1 = "STAT-WATCHING CASUAL";
    private static final String PERSISTENT_CONTROLLER_SLOT_2 = "ULTIMATE AI";

    private class ControllerCounterfactualStrip extends JPanel {
        final JLabel swValue=new JLabel("NOT RETAINED",SwingConstants.CENTER), aiValue=new JLabel("NOT RETAINED",SwingConstants.CENTER);
        ControllerCounterfactualStrip(){
            setOpaque(true);setBackground(new Color(235,246,250));
            setBorder(new CompoundBorder(new LineBorder(new Color(65,145,190),1,true),new EmptyBorder(3,8,3,8)));
            setLayout(new GridLayout(1,2,8,0));add(cell(PERSISTENT_CONTROLLER_SLOT_1,swValue));add(cell(PERSISTENT_CONTROLLER_SLOT_2,aiValue));
            setToolTipText("Controller evidence advances with the visible Frozen hand. Exit result stays fixed; source-supported stay-at-table evidence continues. No cards/actions are invented.");
        }
        JPanel cell(String title,JLabel value){
            JPanel p=new JPanel(new BorderLayout(3,0));p.setOpaque(false);
            JLabel t=new JLabel(title,SwingConstants.CENTER);t.setFont(new Font("SansSerif",Font.BOLD,10));t.setForeground(NAVY);
            value.setFont(new Font("SansSerif",Font.BOLD,13));value.setForeground(new Color(30,105,72));
            p.add(t,BorderLayout.NORTH);p.add(value,BorderLayout.CENTER);return p;
        }
        void showState(Session s,int handNo){
            if(s==null||handNo<=0){swValue.setText("READY — follows Frozen timeline");aiValue.setText("READY — follows Frozen timeline");return;}
            StatWatchView sw=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);
            UltimateAIView ai=parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);
            if(sw!=null&&!sw.stayPath.isEmpty()){
                swValue.setText(statWatchOverlayText(sw,handNo));
                swValue.setForeground(handNo>sw.exitHand&&sw.exitHand>0?new Color(165,75,25):new Color(30,105,72));
            }else{swValue.setText("N/A — no retained stay path");swValue.setForeground(new Color(95,100,105));}
            if(ai!=null&&!ai.path.isEmpty()){
                aiValue.setText(ultimateOverlayText(ai,handNo));
                aiValue.setForeground(handNo>ai.hands&&"AI_EXIT".equalsIgnoreCase(ai.termination)?new Color(165,75,25):new Color(30,105,72));
            }else{aiValue.setText("N/A — no retained chronology");aiValue.setForeground(new Color(95,100,105));}
        }
        String statWatchOverlayText(StatWatchView v,int h){
            int exit=v.exitHand>0?v.exitHand:v.chosenHands;
            if(h<exit){double x=h<v.chosenPath.size()?v.chosenPath.get(h):v.chosenBank;return "CHOSEN = STAY  £"+money(x);}
            double eb=!Double.isNaN(v.exitBank)?v.exitBank:v.chosenBank;
            if(h==exit)return "BEHAVIOURAL EXIT H"+exit+"  £"+money(eb)+"  •  STAY £"+money(h<v.stayPath.size()?v.stayPath.get(h):eb);
            if(h<v.stayPath.size())return "EXIT H"+exit+" £"+money(eb)+"  •  STAY-AT-TABLE H"+h+" £"+money(v.stayPath.get(h));
            return "EXIT H"+exit+" £"+money(eb)+"  •  STAY ENDED H"+v.stayHands+" £"+money(v.stayBank);
        }
        String ultimateOverlayText(UltimateAIView v,int h){
            if(h<v.hands&&h<v.path.size())return "CANONICAL H"+h+"  £"+money(v.path.get(h));
            if(h==v.hands)return "CANONICAL EXIT H"+v.hands+"  £"+money(v.aiBank)+(v.stayPath.isEmpty()?"":"  •  STAY £"+money(v.stayPath.get(Math.min(h,v.stayPath.size()-1))));
            String exit="EXIT H"+v.hands+" £"+money(v.aiBank);
            if(!v.stayPath.isEmpty()){if(h<v.stayPath.size())return exit+"  •  STAY-AT-TABLE H"+h+" £"+money(v.stayPath.get(h));return exit+"  •  STAY ENDED £"+money(v.stayPath.get(v.stayPath.size()-1));}
            return exit+"  •  STAY N/A — NOT RETAINED";
        }
    }

    private static class RecentExperiencePanel extends JPanel {
        RecentExperiencePanel(){
            setOpaque(true); setBackground(new Color(238,245,240));
            setBorder(new CompoundBorder(new LineBorder(new Color(150,185,165)),new EmptyBorder(3,7,3,7)));
            setLayout(new FlowLayout(FlowLayout.CENTER,14,1));
            showHistory(null,-1);
        }
        void showHistory(Session s,int currentIndex){
            removeAll();
            if(s!=null)add(new ControllerIdentityGlyph(controllerIdentityKind(s)));
            JLabel head=new JLabel("<html><b>RECENT PLAYER EXPERIENCE</b><br><br><br><span style='font-size:8px'>NUMBER PLAYER CARDS ON WIN</span></html>"); head.setFont(new Font("SansSerif",Font.BOLD,10)); head.setForeground(NAVY); add(head);
            if(s==null||currentIndex<=0){JLabel n=new JLabel("no previous hands");n.setFont(new Font("SansSerif",Font.PLAIN,10));add(n);revalidate();repaint();return;}
            int from=Math.max(0,currentIndex-5);
            for(int i=from;i<currentIndex;i++){
                Hand h=s.hands.get(i); String out=friendlyOutcome(h.result);
                String shortOut=out.equals("WIN")?"W":out.equals("LOSS")?"L":"P";
                JPanel cell=new JPanel();cell.setOpaque(false);cell.setLayout(new BoxLayout(cell,BoxLayout.Y_AXIS));
                JLabel top=new JLabel(shortOut,SwingConstants.CENTER);
                top.setForeground(shortOut.equals("W")?new Color(25,125,75):shortOut.equals("L")?new Color(190,55,50):new Color(85,95,105));
                top.setFont(new Font("SansSerif",Font.BOLD,17));top.setAlignmentX(Component.CENTER_ALIGNMENT);
                JLabel wager=new JLabel("£"+money(parseMoney(h.wager)),SwingConstants.CENTER);
                wager.setForeground(new Color(55,68,78));wager.setFont(new Font("SansSerif",Font.BOLD,10));wager.setAlignmentX(Component.CENTER_ALIGNMENT);
                JLabel dir=new JLabel("("+directionMarker(s,h)+")",SwingConstants.CENTER);
                dir.setForeground(new Color(115,125,132));dir.setFont(new Font("SansSerif",Font.PLAIN,8));dir.setAlignmentX(Component.CENTER_ALIGNMENT);
                JLabel cardsTaken=new JLabel(playerCardsTakenLabel(h),SwingConstants.CENTER);
                cardsTaken.setForeground(new Color(30,105,170));cardsTaken.setFont(new Font("SansSerif",Font.BOLD,10));cardsTaken.setAlignmentX(Component.CENTER_ALIGNMENT);
                cardsTaken.setToolTipText("Number of player cards on a winning hand: 3, 4+, or — otherwise");
                cell.add(top);cell.add(wager);cell.add(dir);cell.add(cardsTaken);add(cell);
            }
            revalidate();repaint();
        }
    }

    private String actionForReveal(Hand h,int index) {
        int ai=Math.max(0,index-2);
        if(ai<h.actions.size()) return "PLAYER: "+h.actions.get(ai);
        return "PLAYER DRAW";
    }

    private void showSplit(Hand h, int aCount, int bCount) {
        playerCards.setVisible(false); playerOutcomeLabel.setText(" "); playerOutcomeLabel.setVisible(false); splitCards.removeAll();
        List<String> a = h.splitA.subList(0, Math.min(aCount, h.splitA.size()));
        List<String> b = h.splitB.subList(0, Math.min(bCount, h.splitB.size()));
        List<String> ad = new ArrayList<>(), bd = new ArrayList<>();
        for(String c:a) ad.add(splitDisplayCard(c));
        for(String c:b) bd.add(splitDisplayCard(c));
        // v15.10.192: split playback always uses the established compact child-card renderer.
        // This prevents two-card split children from consuming the full 132px normal-card height
        // and permanently reserves room beneath both children for WIN/LOSS/PUSH.
        boolean compactBoth = true;
        // v15.10.357: one split round has one card footprint.  Derive it once from the
        // larger visible child so the shorter branch can never render larger than its sibling.
        // The six-card tier is deliberately tighter than v15.10.356 after Hand 9 UAT showed
        // that 42px could still consume the child lane and clip/wrap the terminal card.
        int[] sharedSplitScale = splitCardScale(Math.max(ad.size(), bd.size()));
        splitCards.add(splitPanel("SPLIT A • TOTAL " + totalText(ad), ad, h.splitAResult, compactBoth, sharedSplitScale));
        splitCards.add(splitPanel("SPLIT B • TOTAL " + totalText(bd), bd, h.splitBResult, compactBoth, sharedSplitScale));
        playerTotalLabel.setText("SPLIT HANDS");
        // Final standalone split UAT: the normal casino chip footer is not part of the
        // split evidence story and consumed the space needed by long split children. Hide
        // it for split playback only; normal hands restore it in the hand reset path.
        chipDisplay.setVisible(false);
        // v15.10.196: retain the complete full-height dealer cards during split playback.
        // Space is reclaimed by constraining the header/action rows above, not by shrinking
        // or clipping dealer cards.
        dealerCards.setPreferredSize(new Dimension(1000,132));
        dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
        for(Component dc : dealerCards.getComponents()) {
            Dimension dd=new Dimension(92,132);
            dc.setPreferredSize(dd); dc.setMinimumSize(dd); dc.setMaximumSize(dd);
        }
        // Keep a fixed settlement lane for compact cards plus WIN/LOSS/PUSH beneath.
        splitCards.setPreferredSize(new Dimension(900,118));
        splitCards.setMaximumSize(new Dimension(900,118));
        splitCards.setVisible(true); splitCards.revalidate(); splitCards.repaint();
    }

    private int[] splitCardScale(int largestChildCount) {
        int w = largestChildCount >= 8 ? 28 : largestChildCount == 7 ? 32 : largestChildCount == 6 ? 36 : largestChildCount == 5 ? 44 : 50;
        int h = largestChildCount >= 8 ? 48 : largestChildCount == 7 ? 52 : largestChildCount == 6 ? 56 : largestChildCount == 5 ? 64 : 68;
        int gap = largestChildCount >= 6 ? 2 : 4;
        return new int[]{w,h,gap};
    }

    private JPanel splitPanel(String title,List<String> cards,String outcome,boolean compactCards,int[] sharedSplitScale) {
        JPanel box=new JPanel(new BorderLayout()); box.setOpaque(false);
        String childVerdict=friendlyOutcome(outcome);
        // At settlement, the visible child verdict must agree with that child's fully resolved
        // retained cards versus the fully resolved retained dealer hand. Prefer that independent
        // resolved comparison when available; retain the parsed child token only as a fallback
        // when the source does not contain enough resolved cards to form the comparison.
        // This is presentation-only and does not alter the retained evidence or settlement.
        if(stage==6 || stage==7) {
            String resolvedChildVerdict=settledSplitChildVerdict(cards);
            if(!resolvedChildVerdict.isBlank()) childVerdict=resolvedChildVerdict;
        }
        JLabel t=new JLabel(title,SwingConstants.CENTER); t.setForeground(Color.WHITE); t.setFont(new Font("SansSerif",Font.BOLD,14)); box.add(t,BorderLayout.NORTH);
        JPanel cp=new JPanel(new FlowLayout(FlowLayout.CENTER,4,0)); cp.setOpaque(false);
        // Standalone split UAT: once a child reaches 3+ cards use a split-only compact
        // renderer footprint. This keeps 3/4-card children on one row at 1080p without
        // changing normal-hand cards or any Compare Mode card renderer.
        int splitCardW = sharedSplitScale[0];
        int splitCardH = sharedSplitScale[1];
        int splitGap = sharedSplitScale[2];
        cp.setLayout(new FlowLayout(FlowLayout.CENTER,splitGap,0));
        for(String c:cards) {
            if(compactCards) {
                CompactCardPanel cc=new CompactCardPanel(splitDisplayCard(c));
                Dimension d=new Dimension(splitCardW,splitCardH); cc.setPreferredSize(d); cc.setMinimumSize(d); cc.setMaximumSize(d); cp.add(cc);
            } else cp.add(new CardPanel(splitDisplayCard(c),false));
        }
        box.add(cp,BorderLayout.CENTER);
        // Never reveal a child result early. Once settlement is reached, put the child
        // verdict beneath that child hand so the two outcomes are visually unambiguous.
        if((stage==6 || stage==7) && !childVerdict.isBlank()) {
            JLabel v=new JLabel(childVerdict,SwingConstants.CENTER);
            v.setFont(new Font("SansSerif",Font.BOLD,13));
            v.setForeground(childVerdict.equals("WIN")?new Color(145,235,165):childVerdict.equals("LOSS")?new Color(255,150,145):new Color(170,205,255));
            v.setBorder(new EmptyBorder(0,0,2,0));
            box.add(v,BorderLayout.SOUTH);
        }
        return box;
    }

    private String settledSplitChildVerdict(List<String> cards) {
        if(cards==null || cards.isEmpty() || currentSession==null || handIndex<0 || handIndex>=currentSession.hands.size()) return "";
        Hand h=currentSession.hands.get(handIndex);
        List<String> dealer=new ArrayList<>();
        if(h.dealerUp!=null && !h.dealerUp.isBlank() && !"?".equals(h.dealerUp)) dealer.add(h.dealerUp);
        if(h.dealerHidden!=null) dealer.addAll(h.dealerHidden);
        if(dealer.size()<2) return "";
        // Split audit evidence can retain a known rank with an unresolved suit (for example "6?").
        // cardTotal() intentionally ignores such tokens in other replay contexts, so use the
        // split-settlement-specific total here: the rank is known and the suit is irrelevant
        // to blackjack value. This fixes S10 H22 Split A: 8+2+5+6? = 21 versus dealer 19.
        int pt=settledSplitTotal(cards), dt=settledSplitTotal(dealer);
        if(pt>21) return "LOSS";
        if(dt>21) return "WIN";
        if(pt>dt) return "WIN";
        if(pt<dt) return "LOSS";
        return "PUSH";
    }

    private static int settledSplitTotal(List<String> xs) {
        int total=0, aces=0;
        if(xs==null) return 0;
        for(String raw:xs) {
            if(raw==null) continue;
            String x=raw.trim().toUpperCase(Locale.ROOT)
                .replace("♥","").replace("♦","").replace("♣","").replace("♠","");
            // In retained split evidence, trailing ?/U means unresolved SUIT, not unresolved rank.
            x=x.replaceAll("[SHDC?U]$", "").trim();
            int v;
            if(x.equals("A")){v=11;aces++;}
            else if(x.matches("10|J|Q|K")) v=10;
            else if(x.matches("[2-9]")) v=Integer.parseInt(x);
            else continue;
            total+=v;
        }
        while(total>21 && aces>0){total-=10;aces--;}
        return total;
    }

    private static String splitDisplayCard(String c) {
        if(c==null) return "?";
        String t=c.trim();
        // Historical split audit text can encode a known rank with unresolved suit as "6?".
        // Render that as the existing unresolved-suit form "6U" so the known rank and total
        // are preserved without inventing a suit.
        if(t.matches("(?i)(A|[2-9]|10|J|Q|K)\\?")) return t.substring(0,t.length()-1)+"U";
        // v15.10.205: recovered card-stream evidence can arrive in canonical ASCII
        // notation (JD, 10H, AS, etc.). Normalise only the suit glyph for display.
        CCard known=cc(t);
        if(known!=null && !"?".equals(known.suit) && !known.suit.isBlank()) return known.toString();
        return t;
    }

    private String handOutcomeText(Hand h) {
        String outcome=friendlyOutcome(h.result);
        double stake=parseMoney(h.committed);
        double amount=0.0;
        if(stake>0) {
            if("WIN".equals(outcome)) amount=stake*(h.natural?1.5:1.0);
            else if("LOSS".equals(outcome) || h.playerBust) amount=stake;
        }
        String moneyPart=(stake>0 && ("WIN".equals(outcome) || "LOSS".equals(outcome) || h.playerBust)) ? " (£"+money(amount)+")" : "";
        if(h.playerBust) return "BUST — LOSS"+moneyPart;
        if(h.natural && "WIN".equals(outcome)) return "BLACKJACK — WIN"+moneyPart;
        if(h.dealerBust && "WIN".equals(outcome)) return "DEALER BUST — WIN"+moneyPart;
        return outcome+moneyPart;
    }

    private static String friendlyOutcome(String r) {
        if(r==null) return "";
        String u=r.trim().toUpperCase();
        if(u.equals("W") || u.startsWith("WIN")) return "WIN";
        if(u.equals("L") || u.startsWith("LOSS")) return "LOSS";
        if(u.equals("P") || u.startsWith("PUSH")) return "PUSH";
        if(u.contains("BUST")) return "BUST";
        return r.trim();
    }

    private void addCard(JPanel panel,String card,boolean hidden) { panel.add(new CardPanel(card,hidden)); adaptReplayCardRow(panel); panel.revalidate(); panel.repaint(); }
    private void addCard(JPanel panel,String card,boolean hidden,boolean hole,boolean invalidPostTerminal) { panel.add(new CardPanel(card,hidden,hole,invalidPostTerminal)); adaptReplayCardRow(panel); panel.revalidate(); panel.repaint(); }
    private void adaptReplayCardRow(JPanel panel) {
        int n=panel.getComponentCount();
        int w=n>=8?54:n==7?60:n==6?70:92;
        int h=n>=8?78:n==7?88:n==6?102:132;
        int gap=n>=7?5:n==6?7:12;
        // v15.10.363-UAT: derived Ultimate AI V2 research replays deliberately give
        // the prospective/evaluation trace visual priority.  Reduce only their centre-table
        // cards by ~15%, creating a stable observation band without shrinking V2 text.
        // Ordinary Historical Replay geometry remains byte-for-byte behaviourally unchanged.
        if(currentSession!=null && currentSession.ultimateV2Replay) {
            w=Math.max(46,(int)Math.round(w*0.85));
            h=Math.max(66,(int)Math.round(h*0.85));
            gap=Math.max(4,(int)Math.round(gap*0.85));
        }
        // v15.10.364-UAT: Ultimate AI V2 replay only — use the space recovered by the
        // compact card scale to lower the PLAYER cards by 26px.  Dealer cards and the V2
        // observation band remain fixed; ordinary Historical Replay retains its established
        // geometry. Presentation only: no evidence, chronology, policy, wager or settlement change.
        boolean v2Player=(currentSession!=null && currentSession.ultimateV2Replay && panel==playerCards);
        // v15.10.367-UAT: final Ultimate AI V2 replay PLAYER/TOTAL separation repair.
        // Keep the approved player-card position fixed while moving the PLAYER/TOTAL label
        // lower and away from the 10G observation line.  Redistribute the same 52px
        // pre-card allowance to 46px header + 6px card top gap; the larger header provides
        // real paint space, while an 18px top inset places the label close to its cards.
        // Ordinary Historical Replay remains unchanged.
        int topGap=v2Player?6:0;
        if(panel==playerCards && panel.getParent()!=null) {
            Container parent=panel.getParent();
            int idx=-1; Component[] siblings=parent.getComponents();
            for(int i=0;i<siblings.length;i++) if(siblings[i]==panel){idx=i;break;}
            if(idx>0 && siblings[idx-1] instanceof JComponent) {
                JComponent header=(JComponent)siblings[idx-1];
                int headerH=v2Player?46:26;
                header.setPreferredSize(new Dimension(1000,headerH));
                header.setMinimumSize(new Dimension(0,headerH));
                header.setMaximumSize(new Dimension(Integer.MAX_VALUE,headerH));
                header.setBorder(v2Player?new EmptyBorder(18,0,0,0):null);
            }
        }
        panel.setLayout(new FlowLayout(FlowLayout.CENTER,gap,topGap));
        for(Component c:panel.getComponents()) { Dimension d=new Dimension(w,h); c.setPreferredSize(d); c.setMinimumSize(d); c.setMaximumSize(d); }
        int laneH=h+topGap;
        panel.setPreferredSize(new Dimension(1000,laneH)); panel.setMaximumSize(new Dimension(Integer.MAX_VALUE,laneH));
    }
    private String resultText(Hand h) {
        String r=h==null?"":h.result;
        String outcome=h==null?"":friendlyOutcome(h.result);
        double stake=h==null?Double.NaN:parseMoney(h.committed);
        double settlement=Double.NaN;
        if(!Double.isNaN(stake) && stake>0) {
            if("WIN".equals(outcome)) settlement=stake*(h.natural?1.5:1.0);
            else if("LOSS".equals(outcome)) settlement=stake;
            else if("PUSH".equals(outcome)) settlement=0.0;
        }
        String amount=Double.isNaN(settlement)?"":" (£"+money(settlement)+")";
        if(r.startsWith("W")) return "✓  WIN" + amount + "   •   " + r;
        if(r.startsWith("L")) return "✕  LOSS" + amount + "   •   " + r;
        if(r.startsWith("P")) return "—  PUSH" + amount + "   •   " + r;
        return r;
    }

    private void refreshTotals(Hand h) {
        List<String> pv = h.split ? Collections.emptyList() : h.player.subList(0, Math.min(playerRevealIndex, h.player.size()));
        if(!h.split) playerTotalLabel.setText("TOTAL " + totalText(pv));
        List<String> dv = new ArrayList<>();
        if(h.dealerUp!=null && !h.dealerUp.equals("?")) dv.add(h.dealerUp);
        for(int i=0;i<Math.min(dealerRevealIndex,h.dealerHidden.size());i++) dv.add(h.dealerHidden.get(i));
        String dt = totalText(dv);
        if(dealerRevealIndex==0 && !h.dealerHidden.isEmpty()) dt += " + ?";
        dealerTotalLabel.setText("TOTAL " + dt);
    }

    private static boolean isTwoCardNatural(List<String> cards) {
        if(cards==null || cards.size()!=2) return false;
        CCard a=cc(cards.get(0)), b=cc(cards.get(1));
        if(a==null || b==null) return false;
        return (a.value==11 && b.value==10) || (a.value==10 && b.value==11);
    }

    private static String totalText(List<String> cards) {
        if(cards==null || cards.isEmpty()) return "--";
        int total=0, aces=0; boolean unknown=false;
        for(String raw:cards){
            if(raw==null || raw.toUpperCase(Locale.ROOT).contains("UNKNOWN")){unknown=true;continue;}
            CCard c=cc(raw);
            if(c==null){unknown=true;continue;}
            total+=c.value; if(c.value==11)aces++;
        }
        while(total>21 && aces>0){total-=10;aces--;}
        return unknown ? (total>0?total+" + ?":"?") : String.valueOf(total);
    }

    private static double parseMoney(String s){
        if(s==null) return 0; try{return Double.parseDouble(s.replace("£","").replace(",","").trim());}catch(Exception e){return 0;}
    }
    private static String money(double v){
        if(Math.abs(v-Math.rint(v))<0.0001) return String.format("%.0f",v);
        return String.format("%.2f",v);
    }

    private static class ChipDisplayPanel extends JComponent {
        double bankroll=0, bet=0;
        boolean engineeringSelection=false;
        void setValues(double bankroll,double bet){this.bankroll=Math.max(0,bankroll);this.bet=Math.max(0,bet);repaint();}
        void setEngineeringSelection(boolean on){engineeringSelection=on;repaint();}
        protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            // v15.10.192: pre-deal overlay only. Place the money display around the same
            // vertical level as the left-side "DEALING • HAND n" status. Because this
            // component spans the felt, every chip retains its true circular geometry.
            int leftCentre = (int)(getWidth() * 0.26);
            int rightCentre = (int)(getWidth() * 0.74);
            // v15.10.349-UAT: on the Engineering selection page the explanatory block
            // occupies the middle felt. Keep BANKROLL / ON TABLE beneath it; ordinary
            // hand playback retains the established 70% chip lane.
            int laneY = Math.max(250, (int)(getHeight() * (engineeringSelection ? 0.93 : 0.70)));
            drawGroup(g, leftCentre, laneY, "BANKROLL", bankroll);
            drawGroup(g, rightCentre, laneY, "ON TABLE", bet);
            g.dispose();
        }
        private void drawGroup(Graphics2D g,int cx,int cy,String label,double amount){
            g.setFont(new Font("SansSerif",Font.BOLD,12)); g.setColor(new Color(238,246,240));
            String text=label+"  £"+money(amount); FontMetrics fm=g.getFontMetrics(); g.drawString(text,cx-fm.stringWidth(text)/2,cy);
            double[] den={25,10,5,2.5}; Color[] col={new Color(40,45,50),new Color(46,110,190),new Color(205,55,55),new Color(235,235,235)};
            double rem=amount; int x=cx-108;
            for(int i=0;i<den.length;i++){
                int count=(int)Math.floor((rem+0.0001)/den[i]); rem-=count*den[i];
                if(count==0){x+=68;continue;}
                int shown=Math.min(count,5); int baseY=cy+66;
                for(int j=0;j<shown;j++) drawChip(g,x,baseY-j*8,52,col[i],den[i]);
                if(count>4){g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,11));g.drawString("×"+count,x+31,baseY-39);}
                x+=68;
            }
        }
        private void drawChip(Graphics2D g,int x,int y,int d,Color c,double value){
            g.setColor(c); g.fillOval(x,y-d,d,d); g.setColor(new Color(250,250,250));g.setStroke(new BasicStroke(2));g.drawOval(x,y-d,d,d);
            g.setStroke(new BasicStroke(1)); for(int a=0;a<360;a+=60){double r=Math.toRadians(a);int x1=(int)(x+d/2+Math.cos(r)*(d/2-4));int y1=(int)(y-d/2+Math.sin(r)*(d/2-4));int x2=(int)(x+d/2+Math.cos(r)*(d/2));int y2=(int)(y-d/2+Math.sin(r)*(d/2));g.drawLine(x1,y1,x2,y2);}
            g.setColor(c.getRed()+c.getGreen()+c.getBlue()>560?new Color(25,25,25):Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,12));String t=value==2.5?"2.5":String.valueOf((int)value);FontMetrics fm=g.getFontMetrics();g.drawString(t,x+d/2-fm.stringWidth(t)/2,y-d/2+3);
        }
    }

    // v15.10.374-UAT: Session Performance P/L label clearance repair; presentation only.
    // v15.10.373-UAT: final Replay canvas geometry + legacy corpus P/L provenance repair.
    // v15.10.372-UAT: consolidated Replay canvas presentation repair: full capture provenance badge, authoritative corpus P/L when available with session-evidence fallback, and compact ordinary outcome lane.
    // v15.10.371-UAT: Replay-only recorded physical-shuffle card-back animation and concise unclipped shuffle messaging; no card-order inference.
    // v15.10.370-UAT: standardized Replay evidence canvas minor repair: capture-type provenance replaces the low-information validated badge; shuffle presentation is concise/prominent; session-average P/L is explicitly labelled as session evidence.
    // v15.10.369-UAT: standardized Replay evidence canvas.  The upper evidence block is
    // common to ordinary, preamble and Ultimate AI V2 replays; only the specialist middle
    // block changes.  DEALING • HAND n remains the prominent universal navigation landmark.
    private static class ShoePanel extends JPanel {
        boolean shuffling=false; int hand=0, previousCards=0, shuffleFrame=0;
        String wagerPolicy="FROZEN POLICY — WAGER";
        String handPolicy="FROZEN POLICY — HAND";
        String streakStatus="CURRENT STREAK — NONE";
        String sourceLabel="6-DECK SOURCE";
        String corpusPnlLabel=null, corpusPnlValue=null; Color corpusPnlFill=null;
        Session session=null; int currentIndex=0;
        ShoePanel(){setOpaque(false);setBorder(new EmptyBorder(10,8,8,8));}
        void reset(){shuffling=false;hand=0;previousCards=0;shuffleFrame=0;wagerPolicy="FROZEN POLICY — WAGER";handPolicy="FROZEN POLICY — HAND";streakStatus="CURRENT STREAK — NONE";sourceLabel="6-DECK SOURCE";corpusPnlLabel=null;corpusPnlValue=null;corpusPnlFill=null;session=null;currentIndex=0;repaint();}
        void setSource(Session s){sourceLabel=(s!=null&&s.realCasino)?"DECK COUNT UNKNOWN":"6-DECK SOURCE";repaint();}
        void setReplayContext(Session s,int idx){session=s;currentIndex=Math.max(0,idx);corpusPnlLabel=null;corpusPnlValue=null;corpusPnlFill=null;repaint();}
        void setCorpusPnl(String label,String value,Color fill){corpusPnlLabel=label;corpusPnlValue=value;corpusPnlFill=fill;repaint();}
        void setPolicyStatus(Hand h){
            double ref=parseMoney(h.refWager), actual=parseMoney(h.wager);
            boolean wagerKnown=!h.refWager.equals("?")&&!h.wager.equals("?");
            wagerPolicy=(wagerKnown && Math.abs(ref-actual)>0.001)?"OVERRIDDEN POLICY — WAGER":"FROZEN POLICY — WAGER";
            handPolicy=Boolean.FALSE.equals(h.frozenFollowed)?"OVERRIDDEN POLICY — HAND":"FROZEN POLICY — HAND"; repaint();
        }
        void setObservedPersonalPolicy(Hand h){wagerPolicy="OBSERVED PERSONAL — WAGER";handPolicy="OBSERVED PERSONAL — HAND";repaint();}
        void setStreak(Session s,int currentIndex){
            int count=0; String kind="";
            for(int i=currentIndex-1;i>=0;i--){String r=friendlyOutcome(s.hands.get(i).result);if(!r.equals("WIN")&&!r.equals("LOSS"))break;if(kind.isEmpty())kind=r;if(!r.equals(kind))break;count++;}
            streakStatus=count==0?"CURRENT STREAK — NONE":"CURRENT STREAK — "+count+" "+(kind.equals("WIN")?(count==1?"WIN":"WINS"):(count==1?"LOSS":"LOSSES")); repaint();
        }
        void startHand(int h){hand=h;shuffling=false;shuffleFrame=0;repaint();}
        void showShuffle(int p,int h){previousCards=p;hand=h;shuffling=true;shuffleFrame=0;repaint();}
        void advanceShuffle(){if(shuffling){shuffleFrame++;repaint();}}
        void finishShuffle(){shuffling=false;shuffleFrame=0;repaint();}
        private void box(Graphics2D g,int x,int y,int w,int h){g.setColor(new Color(8,55,55,220));g.fillRoundRect(x,y,w,h,10,10);g.setColor(new Color(85,155,150));g.setStroke(new BasicStroke(1));g.drawRoundRect(x,y,w,h,10,10);}
        private void row(Graphics2D g,String label,String value,int x,int y,int w,Color fill){g.setFont(new Font("SansSerif",Font.PLAIN,10));g.setColor(new Color(225,240,235));g.drawString(label,x+10,y+14);int vw=(label.startsWith("Capture type")?198:(label.startsWith("Corpus avg P/L")||label.startsWith("Avg P/L (session evidence)")||label.startsWith("Avg P/L (80K corpus")?112:((label.contains("(10A")||label.contains("(10G")||label.contains("(10H"))?130:Math.min(165,(int)(w*0.62)))));g.setColor(fill);g.fillRoundRect(x+w-vw-7,y+2,vw,17,5,5);g.setColor(new Color(18,38,42));g.setFont(new Font("SansSerif",Font.BOLD,(value!=null&&value.length()>20)?8:9));center(g,value,x+w-vw/2-7,y+14);}
        private String captureSource(Session s){String c=s==null?"":(s.envCapture==null?"":s.envCapture.toUpperCase(Locale.ROOT));if(c.contains("SCREEN"))return "SCREENSHOTS";return "LIVE SESSION";}
        private String captureType(Session s){
            if(s==null)return "WAITING";
            String c=s.envCapture==null?"":s.envCapture.toUpperCase(Locale.ROOT);
            boolean shots=c.contains("SCREEN") || s.preambleScreenshot;
            boolean manual=c.contains("MANUAL") || (!c.contains("SCREEN") && !s.standalonePreamble);
            // A linked screenshot preamble followed by the formal live/manual session legitimately
            // contains both capture techniques. A standalone screenshot preamble remains screenshots only.
            if(shots && s.hadPreamble && !s.standalonePreamble)manual=true;
            if(shots && manual)return "SCREENSHOTS + MANUAL ENTRY";
            if(shots)return "SCREENSHOTS";
            return "MANUAL ENTRY";
        }
        private boolean reconstructed(Session s){return s!=null&&(s.standalonePreamble || captureSource(s).equals("SCREENSHOTS"));}
        private int cardsBefore(Session s,int idx){int n=0;if(s!=null)for(int i=0;i<Math.min(idx,s.hands.size());i++)n+=recordedCardSequence(s.hands.get(i)).size();return n;}
        private String shuffleStatus(Session s,int idx){if(s==null)return "NONE OBSERVED";int n=0;for(int i=0;i<Math.min(idx+1,s.hands.size());i++)if(s.hands.get(i).shuffleBefore)n++;return n==0?"NONE OBSERVED":"SHUFFLE "+(char)('A'+Math.min(25,n-1));}
        private double movement(Session s,int idx){if(s==null||idx<=0||s.hands.isEmpty())return 0;int n=Math.min(idx,s.hands.size());return s.hands.get(n-1).researchEnd()-s.hands.get(0).researchStart();}
        private int[] outcomes(Session s,int idx){int[] z={0,0,0};if(s!=null)for(int i=0;i<Math.min(idx,s.hands.size());i++){String r=friendlyOutcome(s.hands.get(i).result);if("WIN".equals(r))z[0]++;else if("LOSS".equals(r))z[1]++;else if("PUSH".equals(r))z[2]++;}return z;}
        private String signedMoney(double v){return (v>0?"+£":v<0?"-£":"£")+String.format(Locale.ROOT,"%.2f",Math.abs(v));}
        protected void paintComponent(Graphics q){super.paintComponent(q);Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int w=getWidth();
            g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,18));center(g,"RECORDED SHOE",w/2,22);g.setColor(new Color(205,230,220));g.setFont(new Font("SansSerif",0,11));center(g,"Historical replay",w/2,39);
            int x=18,y=52,sw=Math.max(145,w-36);g.setColor(new Color(25,31,36));g.fillRoundRect(x,y,sw,155,18,18);g.setColor(new Color(80,90,95));g.setStroke(new BasicStroke(3));g.drawRoundRect(x,y,sw,155,18,18);g.setColor(new Color(215,225,228));g.fillRoundRect(x+25,y+18,sw-50,92,12,12);
            // Replay-only shuffle animation: card backs fan/mix to represent the recorded physical
            // shuffle event. It deliberately does not imply or render any physical card order.
            for(int i=0;i<10;i++){int yy=y+29+i*5;int dx=0,dyCard=0;if(shuffling){int phase=(shuffleFrame+i)%8;dx=(phase<4?phase:7-phase)*((i%2==0)?3:-3);dyCard=((shuffleFrame+i)%3-1)*2;}g.setColor(new Color(24,62,105));g.fillRoundRect(x+44+dx,yy+dyCard,sw-88,48,6,6);g.setColor(Color.WHITE);g.drawRoundRect(x+44+dx,yy+dyCard,sw-88,48,6,6);}g.setColor(TABLE);g.fillRoundRect(x+25,y+118,sw-50,25,8,8);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,10));String shoeBadge=sourceLabel;if(shuffling&&session!=null){String ss=shuffleStatus(session,currentIndex);shoeBadge=ss+" • SHUFFLING";}center(g,shoeBadge,w/2,y+135);
            int bx=10,bw=w-20,ey=216;box(g,bx,ey,bw,137);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,12));g.drawString("RECORDED EVIDENCE",bx+10,ey+17);
            Session s=session;int total=s==null?0:s.hands.size(),idx=Math.min(currentIndex,total);String src=captureSource(s);
            row(g,"Source",src,bx,ey+25,bw,new Color(220,232,238));row(g,"Session position",(hand>0?"HAND "+hand:"WAITING")+(total>0?" OF "+total:""),bx,ey+47,bw,new Color(220,232,238));row(g,"Cards observed",String.valueOf(cardsBefore(s,idx)),bx,ey+69,bw,new Color(220,232,238));row(g,"Recorded shuffle",shuffleStatus(s,idx),bx,ey+91,bw,new Color(220,232,238));row(g,"Capture type",captureType(s),bx,ey+113,bw,new Color(190,235,195));
            int my=360;box(g,bx,my,bw,107);
            if(s!=null&&s.ultimateV2Replay){g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,11));g.drawString("ULTIMATE AI V2 — OBSERVATION",bx+10,my+17);String a=v2Assessment(s,idx),watch=v2Watch(s,idx);int[] z=v2SignalTotals(s,idx);String cal=v2Calibration(z[0],z[1],z[2]);String conf=cal.contains("TESTABLE")?"TESTABLE":cal.contains("ACCUMULATING")?"ACCUMULATING":cal.contains("EARLY")?"TOO EARLY":"INSUFFICIENT";row(g,"Evidence state (10A–10F)",a,bx,my+24,bw,new Color(190,220,245));row(g,"Prospective watch (10G)",watch,bx,my+45,bw,new Color(220,232,238));row(g,"Evidence confidence (10H–10I)",conf,bx,my+66,bw,new Color(245,225,155));row(g,"Decision influence","NONE",bx,my+87,bw,new Color(220,232,238));}
            else {g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,11));g.drawString("SESSION PERFORMANCE",bx+10,my+17);int[] o=outcomes(s,idx);double mv=movement(s,idx);int done=o[0]+o[1]+o[2];String streak=streakStatus.replace("CURRENT STREAK — ","");row(g,"Result so far",o[0]+" WIN / "+o[1]+" LOSS / "+o[2]+" PUSH",bx,my+24,bw,new Color(205,238,205));row(g,"Bankroll movement",signedMoney(mv),bx,my+45,bw,mv>=0?new Color(190,235,195):new Color(245,195,195));row(g,"Current streak",streak,bx,my+66,bw,new Color(220,232,238));if(corpusPnlValue!=null)row(g,corpusPnlLabel,corpusPnlValue,bx,my+87,bw,corpusPnlFill==null?new Color(220,232,238):corpusPnlFill);else row(g,"Avg P/L (session evidence)",done==0?"£0.00":signedMoney(mv/done),bx,my+87,bw,mv>=0?new Color(190,235,195):new Color(245,195,195));}
            int dy=482;box(g,bx,dy,bw,58);g.setColor(new Color(230,245,238));g.setFont(new Font("SansSerif",Font.BOLD,15));center(g,hand>0?"DEALING • HAND "+hand:"WAITING",w/2,dy+24);g.setFont(new Font("SansSerif",0,9));g.setColor(new Color(190,215,205));center(g,"Only recorded shuffles are shown",w/2,dy+43);
            int sy=558;g.setFont(new Font("SansSerif",Font.BOLD,10));g.setColor(wagerPolicy.startsWith("OVERRIDDEN")?new Color(238,198,112):new Color(220,235,228));center(g,wagerPolicy,w/2,sy);g.setColor(handPolicy.startsWith("OVERRIDDEN")?new Color(238,198,112):new Color(220,235,228));center(g,handPolicy,w/2,sy+23);g.setColor(new Color(220,235,228));center(g,streakStatus,w/2,sy+46);
            if(shuffling){g.setColor(new Color(245,214,95));g.setFont(new Font("SansSerif",Font.BOLD,10));center(g,"↻ PHYSICAL SHUFFLE • BEFORE H"+hand,w/2,dy+54);}
            g.dispose();}
        void center(Graphics2D g,String s,int x,int y){FontMetrics f=g.getFontMetrics();g.drawString(s,x-f.stringWidth(s)/2,y);}
    }
    // v15.10.343-UAT: final session-environment vertical-fit tidy; no content/functional change.
    // v15.10.342-UAT: session-selection provenance note for Methods A-E robustness chronology.
    // Current/future sessions use retained per-session adapter evidence when present. Historical
    // Sessions 1-15 explicitly disclose the legacy A-E default chronology from the retained
    // robustness implementation; this is method provenance, not an inference from platform name.
    private String robustnessChronology(Session s){
        if(s==null || s.standalonePreamble) return "UNKNOWN";
        String id=(s.id==null?"":s.id.trim());
        if(!id.isEmpty()){
            List<Path> roots=List.of(Path.of("session_evidence"),Path.of("..","CLI","session_evidence"),Path.of("CLI","session_evidence"));
            for(Path root:roots){
                Path f=root.resolve("blackjack_shuffle_analysis_"+id+".txt");
                if(!Files.isRegularFile(f)) continue;
                try{
                    String t=Files.readString(f,StandardCharsets.UTF_8).toUpperCase(Locale.ROOT);
                    if(t.contains("P1 -> DEALER UP -> P2 -> DEALER HOLE")) return "Dealer hole: Card #4 — Paddy Power A–E";
                    if(t.contains("P1 -> DEALER HOLE -> P2 -> DEALER UP")) return "Dealer hole: Card #2 — 247 A–E";
                }catch(Exception ignored){}
            }
        }
        if(s.publicationNumber>=1 && s.publicationNumber<=15)
            return "Dealer hole: Card #2 — legacy 247 A–E";
        return "UNKNOWN";
    }

    private class PreviousPanel extends JPanel {
        PreviousPanel(){setLayout(new BoxLayout(this,BoxLayout.Y_AXIS));setBackground(new Color(241,245,247));setBorder(new CompoundBorder(new MatteBorder(0,1,0,0,new Color(205,214,218)),new EmptyBorder(12,12,10,12)));clear();}
        JLabel lab(String t,int z,int st,Color c){JLabel l=new JLabel(t);l.setFont(new Font("SansSerif",st,z));l.setForeground(c);l.setAlignmentX(Component.LEFT_ALIGNMENT);return l;}
        JPanel envRow(String key,String value,boolean accent){
            JPanel row=new JPanel(); row.setOpaque(false); row.setLayout(new BoxLayout(row,BoxLayout.Y_AXIS)); row.setAlignmentX(Component.LEFT_ALIGNMENT); row.setMaximumSize(new Dimension(Integer.MAX_VALUE,48));
            JLabel k=lab(key.toUpperCase(),10,Font.BOLD,new Color(92,105,116)); k.setAlignmentX(Component.LEFT_ALIGNMENT); row.add(k); row.add(Box.createVerticalStrut(1));
            JLabel v=lab((value==null||value.isBlank())?"UNKNOWN":value,13,Font.BOLD,accent?new Color(25,105,78):new Color(35,52,68)); v.setAlignmentX(Component.LEFT_ALIGNMENT); row.add(v);
            return row;
        }
        void clear(){clear(null);}
        void clear(Session s){
            removeAll();
            if(s!=null){
                String title=s.standalonePreamble?"PREAMBLE ENVIRONMENT":"SESSION ENVIRONMENT";
                add(lab(title,18,Font.BOLD,NAVY)); add(Box.createVerticalStrut(3));
                add(lab("AT-A-GLANCE SESSION CONTEXT",10,Font.BOLD,new Color(92,105,116))); add(Box.createVerticalStrut(12));
                if(isSemanticQuarantined(s)){
                    add(envRow("Semantic authority","QUARANTINED — forensic replay only; excluded from statistics/research",false)); add(Box.createVerticalStrut(8));
                }
                if(isEngineeringValidationSession(s)){
                    // v15.10.344-UAT: constructed engineering evidence must never inherit an
                    // operator/platform provenance label from the code path it was exercising.
                    add(envRow("Source / origin","ChatGPT Synthesis",true)); add(Box.createVerticalStrut(3));
                    add(envRow("Evidence type","Constructed deterministic UAT cardstream",false)); add(Box.createVerticalStrut(3));
                    add(envRow("Mode under test",(s.architecture==null||s.architecture.isBlank())?"UNKNOWN":s.architecture,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Platform","N/A — synthetic validation",false)); add(Box.createVerticalStrut(3));
                    add(envRow("Starting test bankroll",s.envSessionBankroll.equals("UNKNOWN")?s.envPlatformStart:s.envSessionBankroll,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Session scale",s.envScale,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Observational status","Not observed gameplay",false)); add(Box.createVerticalStrut(3));
                    add(envRow("Research eligibility","Excluded from observational statistics",false)); add(Box.createVerticalStrut(12));
                }else{
                    add(envRow("Platform",(s.platform==null||s.platform.isBlank())?"UNKNOWN":s.platform,true)); add(Box.createVerticalStrut(3));
                    add(envRow("Capture / evidence",s.envCapture,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Mode",(s.architecture==null||s.architecture.isBlank())?"UNKNOWN":s.architecture,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Starting platform balance",s.envPlatformStart,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Declared session bankroll",s.envSessionBankroll,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Session scale",s.envScale,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Preamble / context",s.envPreambleStatus,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Shuffle at start",s.envShuffleStart,false)); add(Box.createVerticalStrut(3));
                    add(envRow("Robustness A–E chronology",robustnessChronology(s),false)); add(Box.createVerticalStrut(3));
                    add(envRow("Publication plate",s.envPlate,true)); add(Box.createVerticalStrut(12));
                }
                JTextArea note=new JTextArea("Evidence context only. UNKNOWN = not established by retained evidence.");
                note.setFont(new Font("SansSerif",Font.PLAIN,11));note.setForeground(new Color(70,82,98));note.setOpaque(false);note.setEditable(false);note.setFocusable(false);note.setLineWrap(true);note.setWrapStyleWord(true);note.setAlignmentX(Component.LEFT_ALIGNMENT);note.setMaximumSize(new Dimension(Integer.MAX_VALUE,42));add(note);
            } else {
                add(lab("PREVIOUS OBSERVATIONS",16,Font.BOLD,NAVY));add(Box.createVerticalStrut(8));add(lab("Comparable starting hands will appear here.",11,0,new Color(80,90,98)));
            }
            revalidate();repaint();
        }
        void showMatches(List<Session> all,Session cur,int hi,Hand now,boolean showCurrent){removeAll();
            if(isSemanticQuarantined(cur)){
                add(lab("QUARANTINED — FORENSIC REPLAY ONLY",15,Font.BOLD,new Color(170,45,45)));
                add(Box.createVerticalStrut(8));
                JTextArea q=new JTextArea("This retained session contains semantic evidence that failed project authority rules. Original cards/outcomes remain visible for forensic inspection only. Comparison, same-state matching, controller research and observational statistics are suppressed for this session.");
                q.setFont(new Font("SansSerif",Font.PLAIN,12));q.setForeground(new Color(55,65,75));q.setOpaque(false);q.setEditable(false);q.setFocusable(false);q.setLineWrap(true);q.setWrapStyleWord(true);q.setAlignmentX(Component.LEFT_ALIGNMENT);q.setMaximumSize(new Dimension(Integer.MAX_VALUE,120));add(q);
                if(now!=null && hasDealerPostTerminalEvidence(now)){
                    add(Box.createVerticalStrut(10));
                    add(lab("HAND AUTHORITY FAILURE",11,Font.BOLD,new Color(170,45,45)));
                    JTextArea hq=new JTextArea("Dealer reached terminal S17 state before a later retained dealer card. The later card is preserved and marked INVALID POST-TERMINAL EVIDENCE; it is not valid gameplay.");
                    hq.setFont(new Font("SansSerif",Font.BOLD,11));hq.setForeground(new Color(120,45,45));hq.setOpaque(false);hq.setEditable(false);hq.setFocusable(false);hq.setLineWrap(true);hq.setWrapStyleWord(true);hq.setAlignmentX(Component.LEFT_ALIGNMENT);hq.setMaximumSize(new Dimension(Integer.MAX_VALUE,95));add(hq);
                }
                revalidate();repaint();return;
            }
            JPanel countWrap=new JPanel(new FlowLayout(FlowLayout.RIGHT,0,0)); countWrap.setOpaque(false);
            countWrap.setMaximumSize(new Dimension(Integer.MAX_VALUE,68));
            countWrap.add(buildHandCountPanel(cur,hi));
            add(Box.createVerticalStrut(2)); add(countWrap); add(Box.createVerticalStrut(4));
            JComponent continuation=buildControllerContinuationPanel(cur,now==null?0:now.number);if(continuation!=null){add(continuation);add(Box.createVerticalStrut(2));}
            add(currentRow(cur,hi,now,showCurrent));
            if(!showCurrent){
                add(Box.createVerticalGlue());
                add(lab("Previous observations unlock when the starting state is complete.",10,Font.BOLD,new Color(85,95,102)));
                revalidate();repaint();return;
            }
            List<Occurrence> ms=new ArrayList<>();int ci=all.indexOf(cur);for(int si=0;si<=ci;si++){Session sess=all.get(si);for(int j=0;j<sess.hands.size();j++){if(si==ci&&j>=hi)break;Hand h=sess.hands.get(j);if(sameState(now,h))ms.add(new Occurrence(displaySessionLabel(sess),sess,j,h));}}
            add(Box.createVerticalStrut(7));
            SameStateCorpusStats stateCorpus=(cur!=null&&cur.standalonePreamble)?loadHistoricalAggregateSameStateCorpusStats(now):loadSameStateCorpusStats(cur,now);
            if(stateCorpus!=null){
                long pn=stateCorpus.frozenHands+stateCorpus.casualHands;
                boolean pk=pn>0&&!Double.isNaN(stateCorpus.frozenPnlSum)&&!Double.isNaN(stateCorpus.casualPnlSum);
                if(pk){double av=(stateCorpus.frozenPnlSum+stateCorpus.casualPnlSum)/pn;shoePanel.setCorpusPnl(stateCorpus.historicalAggregate?"Corpus avg P/L / hand":"Avg P/L (80K corpus • matched hand)",moneyAvg(av),av>0.004?new Color(190,235,195):av<-0.004?new Color(245,195,195):new Color(220,232,238));}
                else if(pn>0){shoePanel.setCorpusPnl(stateCorpus.historicalAggregate?"Corpus avg P/L / hand":"Avg P/L (80K corpus • matched hand)","NOT RETAINED",new Color(220,232,238));}
            }
            if(ms.isEmpty()){
                JLabel none=lab("No previous OBSERVED occurrence recorded.",10,Font.BOLD,new Color(85,95,102));
                none.setAlignmentX(Component.CENTER_ALIGNMENT); add(none);
                if(stateCorpus!=null){ add(Box.createVerticalStrut(7)); add(buildSameStateCorpusPanel(stateCorpus)); }
                revalidate();repaint();return;
            }
            int i=ms.size()-1;add(row(ms.get(i)));
            add(Box.createVerticalStrut(3));
            add(buildSameStateHistoryPanel(ms));
            if(stateCorpus!=null){ add(Box.createVerticalStrut(3)); add(buildSameStateCorpusPanel(stateCorpus)); }
            revalidate();repaint();
        }

        JComponent buildStandalonePreambleCorpusContext(List<Session> all,Session cur,Hand now){
            int sessions=0,hands=0; int ci=all.indexOf(cur);
            for(int si=0;si<ci;si++){Session x=all.get(si);if(x==null||x.standalonePreamble)continue;sessions++;hands+=x.hands.size();}
            String state=corpusStateKey(now); int corpusFiles=0; long corpusMatches=0;
            try{
                List<Path> roots=List.of(Path.of("session_evidence"),Path.of("..","CLI","session_evidence"),Path.of("CLI","session_evidence"));
                Path root=null;for(Path r:roots)if(Files.isDirectory(r)){root=r;break;}
                if(root!=null && state!=null){
                    try(java.util.stream.Stream<Path> st=Files.list(root)){
                        for(Path f:(Iterable<Path>)st.filter(q->q.getFileName().toString().startsWith("blackjack_hnl_same_state_corpus_")&&q.getFileName().toString().endsWith(".tsv"))::iterator){
                            corpusFiles++;
                            for(String line:Files.readAllLines(f,StandardCharsets.UTF_8)){String[] z=line.split("\t");if(z.length>1&&z[0].equals(state)){try{corpusMatches+=Long.parseLong(z[1]);}catch(Exception ignore){}break;}}
                        }
                    }
                }
            }catch(Exception ignore){}
            JPanel outer=new JPanel();outer.setOpaque(true);outer.setBackground(new Color(248,252,253));outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));
            outer.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(5,7,5,7)));
            JLabel t=lab("PRIOR EVIDENCE + ROBUSTNESS CORPORA",9,Font.BOLD,NAVY);t.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(t);
            JLabel o=lab(String.format(Locale.ROOT,"Observed archive searched: %,d hands • %d sessions",hands,sessions),9,Font.BOLD,new Color(70,82,98));o.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(o);
            String c=corpusFiles==0?"Historical A–E same-state corpora: not available":String.format(Locale.ROOT,"Historical A–E same-state corpora: %d • matching hand occurrences: %,d",corpusFiles,corpusMatches);
            JLabel cl=lab(c,9,Font.BOLD,corpusFiles>0?new Color(32,105,72):new Color(85,95,102));cl.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(cl);
            JLabel fw=lab("Historical context only • standalone preamble remains outside formal-session statistics",8,Font.PLAIN,new Color(85,95,102));fw.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(fw);
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,72));return outer;
        }

        JComponent buildControllerContinuationPanel(Session s,int handNo){
            if(s==null||handNo<=0)return null;
            StatWatchView sw=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);
            UltimateAIView ai=parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);
            boolean hasSw=sw!=null&&!sw.stayPath.isEmpty(); boolean hasAi=ai!=null&&!ai.path.isEmpty();
            if(!hasSw&&!hasAi)return null;
            // v15.10.287-UAT: keep both controller summaries full-width on their own lines.
            // This preserves the recovered vertical fit while avoiding horizontal truncation.
            JPanel outer=new JPanel(new GridLayout((hasSw&&hasAi)?2:1,1,0,0));outer.setOpaque(true);outer.setBackground(new Color(248,252,253));
            outer.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(2,5,2,5)));
            if(hasSw)outer.add(controllerContinuationCell(PERSISTENT_CONTROLLER_SLOT_1,statWatchContinuationText(sw,handNo),handNo>sw.exitHand&&sw.exitHand>0));
            if(hasAi)outer.add(controllerContinuationCell(PERSISTENT_CONTROLLER_SLOT_2,ultimateContinuationText(ai,handNo),handNo>ai.hands&&"AI_EXIT".equalsIgnoreCase(ai.termination)));
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,(hasSw&&hasAi)?42:23));outer.setPreferredSize(new Dimension(390,(hasSw&&hasAi)?42:23));
            outer.setToolTipText("Controller exit is kept separate from any source-supported stay-at-table counterfactual. No continuation is invented.");
            return outer;
        }
        JPanel controllerContinuationCell(String title,String value,boolean postExit){
            // v15.10.285-UAT: compact to one line so the canonical three robustness
            // rows (Opening 3 / After Opening 3 / Full Hand) remain visible below.
            JPanel p=new JPanel(new FlowLayout(FlowLayout.LEFT,4,0));p.setOpaque(false);
            p.add(lab(title,8,Font.BOLD,NAVY));
            p.add(lab("•",8,Font.BOLD,new Color(95,105,112)));
            p.add(lab(value,9,Font.BOLD,postExit?new Color(165,75,25):new Color(32,105,72)));
            return p;
        }
        String statWatchContinuationText(StatWatchView v,int h){
            int exit=v.exitHand>0?v.exitHand:v.chosenHands;
            if(h<=exit){double x=h<v.chosenPath.size()?v.chosenPath.get(h):v.chosenBank;return "CHOSEN / STAY  £"+money(x);}
            String exitText="EXIT H"+exit+" £"+money(!Double.isNaN(v.exitBank)?v.exitBank:v.chosenBank);
            if(h<v.stayPath.size())return exitText+"  •  STAY £"+money(v.stayPath.get(h));
            return exitText+"  •  STAY ENDED H"+v.stayHands+" £"+money(v.stayBank);
        }
        String ultimateContinuationText(UltimateAIView v,int h){
            if(h<=v.hands&&h<v.path.size())return (h==v.hands&&"AI_EXIT".equalsIgnoreCase(v.termination)?"EXIT HERE  •  ":"CANONICAL  •  ")+"£"+money(v.path.get(h));
            String exit="EXIT H"+v.hands+" £"+money(v.aiBank);
            if(!v.stayPath.isEmpty()){if(h<v.stayPath.size())return exit+"  •  STAY £"+money(v.stayPath.get(h));return exit+"  •  STAY ENDED £"+money(v.stayPath.get(v.stayPath.size()-1));}
            return exit+"  •  STAY N/A (NOT RETAINED)";
        }

        JPanel buildHandCountPanel(Session s,int handIndex){
            List<String> seen=currentHandVisibleCards(s,handIndex);
            int[] counts=bucketCounts(seen);
            JPanel box=new JPanel(new BorderLayout(8,1));
            box.setBackground(new Color(248,252,253));
            box.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(3,9,3,9)));
            box.setPreferredSize(new Dimension(285,66)); box.setMinimumSize(new Dimension(285,66));
            JLabel title=lab("HAND CARD COUNT • REAL TIME",10,Font.BOLD,NAVY);
            title.setHorizontalAlignment(SwingConstants.CENTER); box.add(title,BorderLayout.NORTH);
            JPanel cells=new JPanel(new GridLayout(1,3,8,0)); cells.setOpaque(false);
            cells.add(countCell("HIGH",counts[0],seen.size(),new Color(25,125,75)));
            cells.add(countCell("NEUTRAL",counts[1],seen.size(),new Color(45,85,165)));
            cells.add(countCell("LOW",counts[2],seen.size(),new Color(190,55,50)));
            box.add(cells,BorderLayout.CENTER);
            JLabel basis=lab(seen.size()+" visible card"+(seen.size()==1?"":"s")+" in current hand",8,Font.BOLD,new Color(90,98,105));
            basis.setHorizontalAlignment(SwingConstants.CENTER); box.add(basis,BorderLayout.SOUTH);
            return box;
        }
        int[] bucketCounts(List<String> cards){
            int high=0,neutral=0,low=0;
            for(String card:cards){
                String m=cardBucketMarker(card);
                if("↑".equals(m))high++; else if("↓".equals(m))low++; else if("–".equals(m))neutral++;
            }
            return new int[]{high,neutral,low};
        }
        List<String> currentHandVisibleCards(Session s,int handIndex){
            List<String> seq=new ArrayList<>();
            if(s==null || handIndex<0 || handIndex>=s.hands.size()) return seq;
            Hand h=s.hands.get(handIndex);
            if(playerRevealIndex>=1 && h.player.size()>=1) seq.add(h.player.get(0));
            if(openingStreamRevealCount>=2 && h.dealerUp!=null && !h.dealerUp.isBlank() && !h.dealerUp.equals("?")) seq.add(h.dealerUp);
            if(playerRevealIndex>=2 && h.player.size()>=2) seq.add(h.player.get(1));
            if(!h.split){
                for(int i=2;i<Math.min(playerRevealIndex,h.player.size());i++) seq.add(h.player.get(i));
            } else {
                for(int i=1;i<Math.min(splitARevealIndex,h.splitA.size());i++) seq.add(h.splitA.get(i));
                for(int i=1;i<Math.min(splitBRevealIndex,h.splitB.size());i++) seq.add(h.splitB.get(i));
            }
            for(int i=0;i<Math.min(dealerRevealIndex,h.dealerHidden.size());i++) seq.add(h.dealerHidden.get(i));
            return seq;
        }
        JPanel countCell(String label,int value,int total,Color color){
            JPanel p=new JPanel();p.setOpaque(false);p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));
            JLabel l=lab(label,8,Font.BOLD,color);l.setAlignmentX(Component.CENTER_ALIGNMENT);
            JLabel v=lab(Integer.toString(value),16,Font.BOLD,color);v.setAlignmentX(Component.CENTER_ALIGNMENT);
            String pct=total>0?String.format(Locale.ROOT,"%.1f%%",100.0*value/total):"0.0%";
            JLabel q=lab(pct,9,Font.BOLD,color);q.setAlignmentX(Component.CENTER_ALIGNMENT);
            p.add(l);p.add(v);p.add(q);return p;
        }
        JPanel aggregateCountCell(String label,String value,Color color){
            JPanel p=new JPanel();p.setOpaque(false);p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));
            JLabel l=lab(label,8,Font.BOLD,color);l.setAlignmentX(Component.CENTER_ALIGNMENT);
            JLabel v=lab(value,19,Font.BOLD,color);v.setAlignmentX(Component.CENTER_ALIGNMENT);
            p.add(l);p.add(v);return p;
        }
        JPanel buildAggregatePanel(List<Occurrence> ms){
            int w=0,l=0,p=0;
            long hs=0,ns=0,ls=0;
            double pnlSum=0.0; boolean pnlKnown=true;
            for(Occurrence o:ms){
                String r=friendlyOutcome(o.h.result);if(r.equals("WIN"))w++;else if(r.equals("LOSS"))l++;else if(r.equals("PUSH"))p++;
                int[] c=bucketCounts(recordedCardSequence(o.h));hs+=c[0];ns+=c[1];ls+=c[2];
                if(!moneyRecorded(o.h.startBank)||!moneyRecorded(o.h.endBank))pnlKnown=false;
                else pnlSum+=(o.h.researchEnd()-o.h.researchStart());
            }
            int n=ms.size();
            String ah=n>0?Long.toString(Math.round((double)hs/n)):"—";
            String an=n>0?Long.toString(Math.round((double)ns/n)):"—";
            String al=n>0?Long.toString(Math.round((double)ls/n)):"—";
            JPanel outer=new JPanel();outer.setOpaque(true);outer.setBackground(new Color(248,252,253));outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));
            outer.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(6,8,6,8)));
            JPanel metrics=new JPanel(new GridLayout(1,2,0,0));metrics.setOpaque(false);metrics.setMaximumSize(new Dimension(Integer.MAX_VALUE,82));
            JPanel comp=new JPanel(new BorderLayout(4,2));comp.setOpaque(false);comp.setBorder(new EmptyBorder(0,0,0,8));
            JLabel ct=lab("Average full hand card count (rounded)",9,Font.BOLD,NAVY);ct.setHorizontalAlignment(SwingConstants.LEFT);comp.add(ct,BorderLayout.NORTH);
            JPanel cc=new JPanel(new GridLayout(1,3,8,0));cc.setOpaque(false);cc.add(aggregateCountCell("HIGH",ah,new Color(25,125,75)));cc.add(aggregateCountCell("NEUTRAL",an,new Color(45,85,165)));cc.add(aggregateCountCell("LOW",al,new Color(190,55,50)));comp.add(cc,BorderLayout.CENTER);
            JPanel pl=new JPanel();pl.setOpaque(false);pl.setLayout(new BoxLayout(pl,BoxLayout.Y_AXIS));pl.setBorder(new CompoundBorder(new MatteBorder(0,1,0,0,new Color(150,190,210)),new EmptyBorder(0,12,0,0)));
            JLabel pt=lab("Average P/L per matched hand",9,Font.BOLD,NAVY);pt.setAlignmentX(Component.CENTER_ALIGNMENT);pl.add(pt);pl.add(Box.createVerticalStrut(7));
            String pv="N/A";Color pc=new Color(85,95,105);
            if(n>0&&pnlKnown){double avg=pnlSum/n;pv=(avg>0.004?"+£":avg<-0.004?"−£":"£")+String.format(Locale.ROOT,"%.2f",Math.abs(avg));pc=avg>0.004?new Color(25,125,75):avg<-0.004?new Color(190,55,50):NAVY;}
            JLabel pvl=lab(pv,20,Font.BOLD,pc);pvl.setAlignmentX(Component.CENTER_ALIGNMENT);pl.add(pvl);
            metrics.add(comp);metrics.add(pl);outer.add(metrics);
            outer.add(Box.createVerticalStrut(4));outer.add(new JSeparator());outer.add(Box.createVerticalStrut(5));
            JLabel total=lab("ALL PREVIOUS OCCURRENCES  •  TOTAL "+n,10,Font.BOLD,NAVY);total.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(total);outer.add(Box.createVerticalStrut(5));
            JPanel outcomes=new JPanel(new GridLayout(1,3,5,0));outcomes.setOpaque(false);
            outcomes.add(outcomeCell("Won",w,n,new Color(32,115,72)));
            outcomes.add(outcomeCell("Lost",l,n,new Color(190,55,50)));
            outcomes.add(outcomeCell("Push",p,n,new Color(35,115,190)));
            outer.add(outcomes);
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,138));
            return outer;
        }

        JPanel buildSameStateHistoryPanel(List<Occurrence> ms){
            int w=0,l=0,p=0; double pnlSum=0.0; boolean pnlKnown=true;
            for(Occurrence o:ms){
                String r=friendlyOutcome(o.h.result);if(r.equals("WIN"))w++;else if(r.equals("LOSS"))l++;else if(r.equals("PUSH"))p++;
                if(!moneyRecorded(o.h.startBank)||!moneyRecorded(o.h.endBank))pnlKnown=false;else pnlSum+=(o.h.researchEnd()-o.h.researchStart());
            }
            int n=ms.size();
            JPanel outer=new JPanel(new BorderLayout(5,2));outer.setOpaque(true);outer.setBackground(new Color(248,252,253));
            outer.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(3,7,3,7)));
            JLabel title=lab("SAME-STATE HISTORY  •  ALL PREVIOUS OCCURRENCES  •  TOTAL "+n,9,Font.BOLD,NAVY);title.setHorizontalAlignment(SwingConstants.CENTER);outer.add(title,BorderLayout.NORTH);
            JPanel outcomes=new JPanel(new GridLayout(1,3,4,0));outcomes.setOpaque(false);
            outcomes.add(outcomeCell("Won",w,n,new Color(32,115,72)));outcomes.add(outcomeCell("Lost",l,n,new Color(190,55,50)));outcomes.add(outcomeCell("Push",p,n,new Color(35,115,190)));outer.add(outcomes,BorderLayout.CENTER);
            String pv="N/A";Color pc=new Color(85,95,105);if(n>0&&pnlKnown){double avg=pnlSum/n;pv=(avg>0.004?"+£":avg<-0.004?"−£":"£")+String.format(Locale.ROOT,"%.2f",Math.abs(avg));pc=avg>0.004?new Color(25,125,75):avg<-0.004?new Color(190,55,50):NAVY;}
            JPanel pl=new JPanel(new FlowLayout(FlowLayout.CENTER,7,0));pl.setOpaque(false);pl.add(lab("AVG P/L PER MATCHED HAND",8,Font.BOLD,NAVY));pl.add(lab(pv,15,Font.BOLD,pc));outer.add(pl,BorderLayout.SOUTH);
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,62));return outer;
        }

        class SameStateCorpusStats {
            String state; int corpusFiles=1; boolean historicalAggregate=false; long hands,frozenHands,casualHands,high,neutral,low,remHigh,remNeutral,remLow,remCards,highDom,neutralDom,lowDom,mix; double frozenPnlSum=Double.NaN,casualPnlSum=Double.NaN;
        }
        String corpusStateKey(Hand h){
            List<String> a=startCards(h); if(a.size()<2||h.dealerUp==null)return null;
            String x=key(a.get(0)),y=key(a.get(1)),u=key(h.dealerUp); if(x.compareTo(y)>0){String t=x;x=y;y=t;} return x+","+y+"|"+u;
        }
        SameStateCorpusStats loadSameStateCorpusStats(Session s,Hand h){
            String k=corpusStateKey(h); if(k==null||s==null||s.id==null||s.id.isBlank())return null;
            String base=s.id.replace("_CORRECTED_247_DEAL_ORDER","").replace("_CORRECTED","");
            String corpusName="blackjack_hnl_same_state_corpus_"+base+".tsv";
            List<Path> candidates=List.of(Path.of("session_evidence",corpusName),Path.of("..","CLI","session_evidence",corpusName),Path.of("..","session_evidence",corpusName),Path.of(corpusName));
            Path f=null;for(Path q:candidates)if(Files.exists(q)){f=q;break;}if(f==null)return null;
            try{
                for(String line:Files.readAllLines(f,StandardCharsets.UTF_8)){
                    if(line.startsWith("state\t"))continue;String[] z=line.split("\\t");if(z.length<15||!z[0].equals(k))continue;
                    SameStateCorpusStats c=new SameStateCorpusStats();c.state=z[0];c.hands=Long.parseLong(z[1]);c.frozenHands=Long.parseLong(z[2]);c.casualHands=Long.parseLong(z[3]);c.high=Long.parseLong(z[4]);c.neutral=Long.parseLong(z[5]);c.low=Long.parseLong(z[6]);c.remHigh=Long.parseLong(z[7]);c.remNeutral=Long.parseLong(z[8]);c.remLow=Long.parseLong(z[9]);c.remCards=Long.parseLong(z[10]);c.highDom=Long.parseLong(z[11]);c.neutralDom=Long.parseLong(z[12]);c.lowDom=Long.parseLong(z[13]);c.mix=Long.parseLong(z[14]);if(z.length>=17){c.frozenPnlSum=Double.parseDouble(z[15]);c.casualPnlSum=Double.parseDouble(z[16]);}return c;
                }
            }catch(Exception ex){return null;}return null;
        }
        SameStateCorpusStats loadHistoricalAggregateSameStateCorpusStats(Hand h){
            String k=corpusStateKey(h); if(k==null)return null;
            SameStateCorpusStats a=new SameStateCorpusStats();a.state=k;a.corpusFiles=0;a.historicalAggregate=true;a.frozenPnlSum=0;a.casualPnlSum=0;
            try{
                List<Path> roots=List.of(Path.of("session_evidence"),Path.of("..","CLI","session_evidence"),Path.of("CLI","session_evidence"));
                Path root=null;for(Path r:roots)if(Files.isDirectory(r)){root=r;break;}if(root==null)return null;
                try(java.util.stream.Stream<Path> st=Files.list(root)){
                    for(Path f:(Iterable<Path>)st.filter(q->q.getFileName().toString().startsWith("blackjack_hnl_same_state_corpus_")&&q.getFileName().toString().endsWith(".tsv"))::iterator){
                        for(String line:Files.readAllLines(f,StandardCharsets.UTF_8)){String[] z=line.split("\t");if(z.length<15||!z[0].equals(k))continue;
                            a.corpusFiles++;a.hands+=Long.parseLong(z[1]);a.frozenHands+=Long.parseLong(z[2]);a.casualHands+=Long.parseLong(z[3]);a.high+=Long.parseLong(z[4]);a.neutral+=Long.parseLong(z[5]);a.low+=Long.parseLong(z[6]);a.remHigh+=Long.parseLong(z[7]);a.remNeutral+=Long.parseLong(z[8]);a.remLow+=Long.parseLong(z[9]);a.remCards+=Long.parseLong(z[10]);a.highDom+=Long.parseLong(z[11]);a.neutralDom+=Long.parseLong(z[12]);a.lowDom+=Long.parseLong(z[13]);a.mix+=Long.parseLong(z[14]);if(z.length>=17){a.frozenPnlSum+=Double.parseDouble(z[15]);a.casualPnlSum+=Double.parseDouble(z[16]);}break;}
                    }
                }
            }catch(Exception ex){return null;}return a.corpusFiles>0?a:null;
        }
        String moneyAvg(double v){return (v>0.004?"+£":v<-0.004?"−£":"£")+String.format(Locale.ROOT,"%.2f",Math.abs(v));}
        Color pnlColor(double sum,long n){if(Double.isNaN(sum)||n==0)return new Color(85,95,105);double v=sum/n;return v>0.004?new Color(25,125,75):v<-0.004?new Color(190,55,50):NAVY;}
        double pct(long x,long n){return n>0?100.0*x/n:0.0;}
        JPanel buildSameStateCorpusPanel(SameStateCorpusStats c){
            JPanel outer=new JPanel();outer.setOpaque(true);outer.setBackground(new Color(248,252,253));outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));outer.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(2,7,2,7)));
            // v15.10.328-UAT: restore the accepted corpus header unchanged.  The separate
            // Frozen/Casual AVG P/L / MATCHED HAND row below is the authoritative P/L summary.
            String corpusTitle=c.historicalAggregate?"SAME-STATE ROBUSTNESS  •  HISTORICAL A–E CORPORA":"SAME-STATE ROBUSTNESS  •  CURRENT OBSERVED SESSION";
            JLabel title=lab(corpusTitle,9,Font.BOLD,NAVY);title.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(title);
            JLabel scope=new JLabel(c.historicalAggregate?String.format(Locale.ROOT,"%d RETAINED SAME-STATE CORPORA  •  Frozen + Casual",c.corpusFiles):"80,000 REPLAY PATHS SEARCHED  •  A–E family  •  Frozen + Casual",SwingConstants.CENTER); scope.setFont(new Font("SansSerif",Font.PLAIN,7)); scope.setForeground(new Color(80,90,98)); scope.setAlignmentX(Component.CENTER_ALIGNMENT); scope.setMaximumSize(new Dimension(Integer.MAX_VALUE,12)); outer.add(scope);
            String stateText=c.state==null?"current state":c.state.replace("|"," vs dealer ").replace(",",",");
            if(stateText.contains(" vs dealer 10"))stateText=stateText.replace(" vs dealer 10"," vs dealer 10-value (10/J/Q/K)");
            JLabel explain=new JLabel(c.historicalAggregate?String.format(Locale.ROOT,"Across retained historical corpora, %s occurred %,d times.",stateText,c.hands):String.format(Locale.ROOT,"Across all 80,000 replay journeys, %s occurred %,d times.",stateText,c.hands),SwingConstants.CENTER); explain.setFont(new Font("SansSerif",Font.BOLD,8)); explain.setForeground(new Color(55,65,75)); explain.setAlignmentX(Component.CENTER_ALIGNMENT); explain.setMaximumSize(new Dimension(Integer.MAX_VALUE,14)); outer.add(explain);
            JLabel sub=lab(String.format(Locale.ROOT,"%,d MATCHING HAND OCCURRENCES  •  Frozen %,d  •  Casual %,d",c.hands,c.frozenHands,c.casualHands),8,Font.BOLD,NAVY);sub.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(sub);
            boolean legacyPnlMissing=Double.isNaN(c.frozenPnlSum)&&Double.isNaN(c.casualPnlSum);
            String favg=Double.isNaN(c.frozenPnlSum)||c.frozenHands==0?"N/A":moneyAvg(c.frozenPnlSum/c.frozenHands);
            String cavg=Double.isNaN(c.casualPnlSum)||c.casualHands==0?"N/A":moneyAvg(c.casualPnlSum/c.casualHands);
            JPanel pnl=new JPanel(new FlowLayout(FlowLayout.CENTER,8,0));pnl.setOpaque(false);
            if(legacyPnlMissing){
                pnl.add(lab("AVG P/L / MATCHED HAND",9,Font.BOLD,NAVY));
                pnl.add(lab("LEGACY CORPUS • P/L NOT RETAINED",9,Font.BOLD,new Color(85,95,105)));
            } else {
                pnl.add(lab("AVG P/L / MATCHED HAND",9,Font.BOLD,NAVY));
                pnl.add(lab("Frozen "+favg,10,Font.BOLD,pnlColor(c.frozenPnlSum,c.frozenHands)));
                pnl.add(lab("•",9,Font.BOLD,new Color(95,105,112)));
                pnl.add(lab("Casual "+cavg,10,Font.BOLD,pnlColor(c.casualPnlSum,c.casualHands)));
            }
            pnl.setMaximumSize(new Dimension(Integer.MAX_VALUE,16));outer.add(pnl);
            long oh=c.high-c.remHigh,on=c.neutral-c.remNeutral,ol=c.low-c.remLow,open=oh+on+ol,full=c.high+c.neutral+c.low;
            JPanel rows=new JPanel(new GridLayout(3,1,0,0));rows.setOpaque(false);
            rows.add(corpusHnlRow("OPENING 3",pct(oh,open),pct(on,open),pct(ol,open)));
            rows.add(corpusHnlRow("AFTER OPENING 3",pct(c.remHigh,c.remCards),pct(c.remNeutral,c.remCards),pct(c.remLow,c.remCards)));
            rows.add(corpusHnlRow("FULL HAND • ALL CARDS",pct(c.high,full),pct(c.neutral,full),pct(c.low,full)));outer.add(rows);
            // Presentation-only simplification: the robustness panel ends after the three
            // H/N/L composition rows. Individual-hand dominance and average-additional-card
            // summaries remain in the corpus evidence but are intentionally not displayed.
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,108));return outer;
        }

        class CorpusStats {
            int pairs,paths,reservoir; long hands; double oh,on,ol,rh,rn,rl,fh,fn,fl,avgAdd,hh,nh,lh,mix;
        }
        CorpusStats loadCorpusStats(Session s){
            if(s==null||s.id==null||s.id.isBlank())return null;
            String base=s.id.replace("_CORRECTED_247_DEAL_ORDER","").replace("_CORRECTED","");
            List<Path> candidates=List.of(
                Path.of("session_evidence","blackjack_hnl_corpus_"+base+".txt"),
                Path.of("..","CLI","session_evidence","blackjack_hnl_corpus_"+base+".txt"),
                Path.of("CLI","session_evidence","blackjack_hnl_corpus_"+base+".txt"));
            Path f=null;for(Path q:candidates)if(Files.exists(q)){f=q;break;}
            try{
                Map<String,String> m=new LinkedHashMap<>();
                if(f!=null){
                    for(String line:Files.readAllLines(f,StandardCharsets.UTF_8)){int x=line.indexOf('=');if(x>0)m.put(line.substring(0,x).trim(),line.substring(x+1).trim());}
                } else if(base.equals("20260913_031649")) {
                    // Frozen research reference generated from the combined Session 11 A/B/C1/C2/C3/C4/D/E robustness corpora.
                    // Embedded only to make the research panel independent of Windows launch/deployment path.
                    m.put("PAIRS","40000");m.put("POLICY_PATHS","80000");m.put("RESERVOIR_CARDS","164");m.put("RESOLVED_HANDS","2007421");
                    m.put("OPEN_HIGH_PCT","36.784");m.put("OPEN_NEUTRAL_PCT","24.213");m.put("OPEN_LOW_PCT","39.003");
                    m.put("REMAINDER_HIGH_PCT","36.886");m.put("REMAINDER_NEUTRAL_PCT","24.318");m.put("REMAINDER_LOW_PCT","38.796");m.put("FULL_HIGH_PCT","36.830");m.put("FULL_NEUTRAL_PCT","24.260");m.put("FULL_LOW_PCT","38.909");m.put("AVG_ADDITIONAL_CARDS","2.487");
                    m.put("HIGH_HEAVY_PCT","26.601");m.put("NEUTRAL_HEAVY_PCT","12.722");m.put("LOW_HEAVY_PCT","23.413");m.put("MIXED_TIE_PCT","37.264");
                } else return null;
                CorpusStats c=new CorpusStats();c.pairs=Integer.parseInt(m.getOrDefault("PAIRS","0"));c.paths=Integer.parseInt(m.getOrDefault("POLICY_PATHS","0"));c.reservoir=Integer.parseInt(m.getOrDefault("RESERVOIR_CARDS","0"));c.hands=Long.parseLong(m.getOrDefault("RESOLVED_HANDS","0"));
                c.oh=Double.parseDouble(m.getOrDefault("OPEN_HIGH_PCT","NaN"));c.on=Double.parseDouble(m.getOrDefault("OPEN_NEUTRAL_PCT","NaN"));c.ol=Double.parseDouble(m.getOrDefault("OPEN_LOW_PCT","NaN"));
                c.rh=Double.parseDouble(m.getOrDefault("REMAINDER_HIGH_PCT","NaN"));c.rn=Double.parseDouble(m.getOrDefault("REMAINDER_NEUTRAL_PCT","NaN"));c.rl=Double.parseDouble(m.getOrDefault("REMAINDER_LOW_PCT","NaN"));c.avgAdd=Double.parseDouble(m.getOrDefault("AVG_ADDITIONAL_CARDS","NaN"));
                double denom=3.0+c.avgAdd;
                c.fh=Double.parseDouble(m.getOrDefault("FULL_HIGH_PCT",Double.toString((3.0*c.oh+c.avgAdd*c.rh)/denom)));
                c.fn=Double.parseDouble(m.getOrDefault("FULL_NEUTRAL_PCT",Double.toString((3.0*c.on+c.avgAdd*c.rn)/denom)));
                c.fl=Double.parseDouble(m.getOrDefault("FULL_LOW_PCT",Double.toString((3.0*c.ol+c.avgAdd*c.rl)/denom)));
                c.hh=Double.parseDouble(m.getOrDefault("HIGH_HEAVY_PCT","NaN"));c.nh=Double.parseDouble(m.getOrDefault("NEUTRAL_HEAVY_PCT","NaN"));c.lh=Double.parseDouble(m.getOrDefault("LOW_HEAVY_PCT","NaN"));c.mix=Double.parseDouble(m.getOrDefault("MIXED_TIE_PCT","NaN"));return c;
            }catch(Exception ex){return null;}
        }
        JPanel buildCorpusPanel(CorpusStats c){
            JPanel outer=new JPanel();outer.setOpaque(true);outer.setBackground(new Color(248,252,253));outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));outer.setBorder(new CompoundBorder(new LineBorder(new Color(55,145,195),1,true),new EmptyBorder(4,7,4,7)));
            JLabel title=lab("CARD-STREAM REFERENCE  •  ALL ROBUSTNESS CORPORA A–E",9,Font.BOLD,NAVY);title.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(title);
            JLabel sub=lab(String.format(Locale.ROOT,"%,d paired replays  •  %,d policy paths  •  %,d resolved hands",c.pairs,c.paths,c.hands),7,Font.PLAIN,new Color(80,90,98));sub.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(sub);
            JPanel rows=new JPanel(new GridLayout(3,1,0,0));rows.setOpaque(false);
            rows.add(corpusHnlRow("OPENING 3",c.oh,c.on,c.ol));rows.add(corpusHnlRow("AFTER OPENING 3",c.rh,c.rn,c.rl));rows.add(corpusHnlRow("FULL HAND • ALL CARDS",c.fh,c.fn,c.fl));outer.add(rows);
            JPanel development=new JPanel(new BorderLayout(6,0));development.setOpaque(false);development.setBorder(new MatteBorder(1,0,0,0,new Color(190,210,220)));
            JPanel avg=new JPanel(new FlowLayout(FlowLayout.LEFT,3,1));avg.setOpaque(false);avg.add(lab("AVG ADDITIONAL CARDS",7,Font.BOLD,NAVY));avg.add(lab(String.format(Locale.ROOT,"%.2f",c.avgAdd),11,Font.BOLD,NAVY));development.add(avg,BorderLayout.WEST);
            JPanel dev=new JPanel();dev.setOpaque(false);dev.setLayout(new BoxLayout(dev,BoxLayout.Y_AXIS));
            JLabel dt=lab("HOW INDIVIDUAL HANDS DEVELOPED AFTER OPENING 3",7,Font.BOLD,NAVY);dt.setAlignmentX(Component.CENTER_ALIGNMENT);dev.add(dt);
            JLabel dv=lab(String.format(Locale.ROOT,"HIGH dominated %.1f%%  •  NEUTRAL dominated %.1f%%  •  LOW dominated %.1f%%  •  NO SINGLE TYPE %.1f%%",c.hh,c.nh,c.lh,c.mix),7,Font.BOLD,new Color(70,82,98));dv.setAlignmentX(Component.CENTER_ALIGNMENT);dev.add(dv);development.add(dev,BorderLayout.CENTER);outer.add(development);
            JLabel note=lab("Descriptive corpus context only • not a prediction of the next card",7,Font.PLAIN,new Color(100,105,110));note.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(note);
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));return outer;
        }
        JPanel corpusHnlRow(String name,double h,double n,double l){
            JPanel r=new JPanel(new BorderLayout(6,0));r.setOpaque(false);JLabel nm=lab(name,8,Font.BOLD,NAVY);nm.setPreferredSize(new Dimension(92,15));r.add(nm,BorderLayout.WEST);
            JPanel vals=new JPanel(new GridLayout(1,3,6,0));vals.setOpaque(false);vals.add(corpusPct("HIGH",h,new Color(25,125,75)));vals.add(corpusPct("NEUTRAL",n,new Color(45,85,165)));vals.add(corpusPct("LOW",l,new Color(190,55,50)));r.add(vals,BorderLayout.CENTER);return r;
        }
        JPanel corpusPct(String name,double v,Color color){JPanel p=new JPanel(new FlowLayout(FlowLayout.CENTER,3,0));p.setOpaque(false);p.add(lab(name,8,Font.BOLD,color));p.add(lab(String.format(Locale.ROOT,"%.1f%%",v),10,Font.BOLD,color));return p;}

        boolean moneyRecorded(String v){if(v==null)return false;String t=v.trim();return !t.isEmpty()&&!t.equals("?")&&t.matches(".*\\d.*");}
        JPanel outcomeCell(String name,int count,int total,Color color){
            JPanel p=new JPanel(new FlowLayout(FlowLayout.CENTER,0,0));p.setOpaque(false);
            String pct=total>0?String.format(Locale.ROOT,"%.1f%%",100.0*count/total):"0.0%";
            p.add(lab(name+" "+count+"  •  "+pct,10,Font.BOLD,color));return p;
        }
        JPanel currentRow(Session s,int handIndex,Hand h,boolean completeState){
            JPanel b=new JPanel(new BorderLayout(3,1));b.setBackground(new Color(248,252,253));b.setBorder(new CompoundBorder(new LineBorder(new Color(120,155,170)),new EmptyBorder(3,5,3,5)));
            JPanel top=new JPanel(new BorderLayout());top.setOpaque(false);
            top.add(lab("CURRENT • "+displaySessionLabel(s)+" • HAND "+h.number,9,Font.BOLD,NAVY),BorderLayout.WEST);
            JLabel currentStake=lab("£"+h.wager,15,Font.BOLD,NAVY);currentStake.setHorizontalAlignment(SwingConstants.CENTER);currentStake.setBorder(new EmptyBorder(0,18,0,0));top.add(currentStake,BorderLayout.CENTER);
            JLabel topHoleLabel=lab("HOLE CARD",7,Font.BOLD,NAVY);topHoleLabel.setHorizontalAlignment(SwingConstants.CENTER);topHoleLabel.setPreferredSize(new Dimension(92,12));top.add(topHoleLabel,BorderLayout.EAST);b.add(top,BorderLayout.NORTH);
            JPanel middle=new JPanel(new BorderLayout(6,0));middle.setOpaque(false);middle.setPreferredSize(new Dimension(390,54));middle.setMinimumSize(new Dimension(390,54));
            JPanel c=new JPanel(new FlowLayout(FlowLayout.LEFT,3,1));c.setOpaque(false);
            int pc=Math.min(playerRevealIndex,Math.min(2,h.player.size()));for(int i=0;i<pc;i++)c.add(new MiniCard(h.player.get(i)));
            boolean dealerUpVisible=stage>=2 || completeState || dealerRevealIndex>0;if(dealerUpVisible){c.add(new JLabel(" vs "));c.add(new MiniCard(h.dealerUp));}middle.add(c,BorderLayout.WEST);
            JPanel holeWrap=new JPanel(new FlowLayout(FlowLayout.CENTER,0,0));holeWrap.setOpaque(false);holeWrap.setPreferredSize(new Dimension(92,50));
            JPanel holeCardRow=new JPanel(new FlowLayout(FlowLayout.CENTER,0,0));holeCardRow.setOpaque(false);
            if(openingStreamRevealCount>=2 && !h.dealerHidden.isEmpty())holeCardRow.add(new HoleReplayCard(h.dealerHidden.get(0),dealerRevealIndex==0));else holeCardRow.add(lab("—",12,Font.BOLD,new Color(110,115,120)));
            holeWrap.add(holeCardRow);middle.add(holeWrap,BorderLayout.EAST);b.add(middle,BorderLayout.CENTER);
            List<PriorCardInfo> prior=priorCards(s,handIndex,5);
            if(!prior.isEmpty()){
                JPanel pcards=new JPanel(new FlowLayout(FlowLayout.LEFT,5,10));pcards.setOpaque(false);pcards.add(lab("5 CARDS BEFORE",8,Font.BOLD,NAVY));for(PriorCardInfo x:prior)pcards.add(new TinyCard(x.card,x.hole));
                b.add(pcards,BorderLayout.SOUTH);
            }
            // The retained 5-card history strip consumes SOUTH height. Give only that state
            // a little extra vertical allowance so the 36x50 revealed hole card is never clipped.
            b.setMaximumSize(new Dimension(Integer.MAX_VALUE,prior.isEmpty()?112:150));
            b.setPreferredSize(new Dimension(390,prior.isEmpty()?112:150));return b;
        }
        JPanel row(Occurrence o){
            JPanel b=new JPanel(new BorderLayout(3,1));b.setBackground(new Color(248,252,253));b.setBorder(new CompoundBorder(new LineBorder(new Color(120,155,170)),new EmptyBorder(3,5,3,5)));
            JPanel top=new JPanel(new BorderLayout());top.setOpaque(false);
            top.add(lab("PREVIOUS • "+o.sessionLabel+" • HAND "+o.h.number,9,Font.BOLD,NAVY),BorderLayout.WEST);
            JLabel previousStake=lab("£"+o.h.wager,15,Font.BOLD,NAVY);previousStake.setHorizontalAlignment(SwingConstants.CENTER);previousStake.setBorder(new EmptyBorder(0,18,0,0));top.add(previousStake,BorderLayout.CENTER);
            JLabel topHoleLabel=lab("HOLE CARD",7,Font.BOLD,NAVY);topHoleLabel.setHorizontalAlignment(SwingConstants.CENTER);topHoleLabel.setPreferredSize(new Dimension(92,12));top.add(topHoleLabel,BorderLayout.EAST);b.add(top,BorderLayout.NORTH);
            JPanel middle=new JPanel(new BorderLayout(8,0));middle.setOpaque(false);
            JPanel c=new JPanel(new FlowLayout(FlowLayout.LEFT,3,0));c.setOpaque(false);for(String x:startCards(o.h))c.add(new MiniCard(x));c.add(new JLabel(" vs "));c.add(new MiniCard(o.h.dealerUp));middle.add(c,BorderLayout.WEST);
            JPanel resultBox=new JPanel();resultBox.setOpaque(false);resultBox.setLayout(new BoxLayout(resultBox,BoxLayout.Y_AXIS));
            String a=o.h.actions.isEmpty()?"—":o.h.actions.get(0);String outcome=friendlyOutcome(o.h.result);String outcomeColor=outcome.equals("WIN")?"#207348":outcome.equals("LOSS")?"#b93737":"#3978aa";
            JLabel result=new JLabel("<html><b>"+a+" • <font color='"+outcomeColor+"'>"+outcome+"</font></b></html>");result.setFont(new Font("SansSerif",Font.BOLD,11));result.setForeground(NAVY);result.setAlignmentX(Component.LEFT_ALIGNMENT);resultBox.add(result);
            boolean wagerKnown=!o.h.refWager.equals("?")&&!o.h.wager.equals("?");boolean wagerFollowed=wagerKnown && Math.abs(parseMoney(o.h.refWager)-parseMoney(o.h.wager))<0.001;boolean cardFollowed=Boolean.TRUE.equals(o.h.frozenFollowed);
            String st; Color sc;if(cardFollowed && wagerFollowed){st="FROZEN FOLLOWED • CARD + WAGER";sc=new Color(32,115,72);}else if(cardFollowed){st="FROZEN FOLLOWED • CARD ONLY";sc=new Color(32,115,72);}else if(wagerFollowed){st="FROZEN FOLLOWED • WAGER ONLY";sc=new Color(32,115,72);}else if(o.h.frozenFollowed==null && !wagerKnown){st="FROZEN FOLLOWED • NOT DETERMINABLE";sc=Color.GRAY;}else {st="FROZEN NOT FOLLOWED";sc=new Color(175,105,30);}JLabel stl=lab(st,7,Font.BOLD,sc);stl.setAlignmentX(Component.LEFT_ALIGNMENT);resultBox.add(stl);middle.add(resultBox,BorderLayout.CENTER);
            JPanel hole=new JPanel(new FlowLayout(FlowLayout.CENTER,0,0));hole.setOpaque(false);hole.setPreferredSize(new Dimension(92,50));JPanel hc=new JPanel(new FlowLayout(FlowLayout.CENTER,0,0));hc.setOpaque(false);if(!o.h.dealerHidden.isEmpty())hc.add(new MiniCard(o.h.dealerHidden.get(0),true));else hc.add(lab("not recorded",7,Font.PLAIN,new Color(110,115,120)));hole.add(hc);middle.add(hole,BorderLayout.EAST);
            b.add(middle,BorderLayout.CENTER);b.setMaximumSize(new Dimension(Integer.MAX_VALUE,82));b.setPreferredSize(new Dimension(390,82));return b;
        }
    }
    private static class Occurrence{String sessionLabel;Session session;int handIndex;Hand h;Occurrence(String sessionLabel,Session session,int handIndex,Hand h){this.sessionLabel=sessionLabel;this.session=session;this.handIndex=handIndex;this.h=h;}}
    private static class PriorCardInfo { final String card; final boolean hole; PriorCardInfo(String card,boolean hole){this.card=card;this.hole=hole;} }
    private static List<PriorCardInfo> priorCards(Session s,int handIndex,int count){List<PriorCardInfo> seq=new ArrayList<>();for(int i=0;i<handIndex;i++){Hand h=s.hands.get(i);List<String> cards=recordedCardSequence(h);for(int j=0;j<cards.size();j++)seq.add(new PriorCardInfo(cards.get(j),j==1&&!h.dealerHidden.isEmpty()));}int from=Math.max(0,seq.size()-count);return new ArrayList<>(seq.subList(from,seq.size()));}
    private static List<String> recordedCardSequence(Hand h){List<String> r=new ArrayList<>();if(h.player.size()>0)r.add(h.player.get(0));if(h.dealerHidden.size()>0)r.add(h.dealerHidden.get(0));if(h.player.size()>1)r.add(h.player.get(1));if(h.dealerUp!=null&&!h.dealerUp.trim().isEmpty()&&!h.dealerUp.equals("?"))r.add(h.dealerUp);if(h.split){if(h.splitA.size()>1)r.addAll(h.splitA.subList(1,h.splitA.size()));if(h.splitB.size()>1)r.addAll(h.splitB.subList(1,h.splitB.size()));}else if(h.player.size()>2)r.addAll(h.player.subList(2,h.player.size()));if(h.dealerHidden.size()>1)r.addAll(h.dealerHidden.subList(1,h.dealerHidden.size()));return r;}
    private static String publicationSessionLabel(String id){
        if(id==null)return "SESSION";
        if(id.contains("20260903_004704"))return "SESSION 1";
        if(id.contains("20260903_221223"))return "SESSION 2";
        if(id.contains("20260905_174453"))return "SESSION 3";
        if(id.contains("20260903_135610"))return "INTERIM SESSION";
        return "SESSION "+id;
    }
    private static String displaySessionLabel(Session s){
        if(s==null)return "SESSION";
        String legacy=publicationSessionLabel(s.id);
        if(s.publicationNumber>0) return "SESSION "+s.publicationNumber;
        if(s.id!=null && (s.id.contains("20260903_004704") || s.id.contains("20260903_221223") || s.id.contains("20260905_174453"))) return legacy;
        return legacy;
    }
    private static String sessionDisplayTitle(Session s){
        if(s!=null&&s.ultimateV2Replay) return "SESSION "+s.publicationNumber+" - ULTIMATE AI V2 REPLAY";
        if(s!=null&&s.standalonePreamble){String p=(s.platform==null||s.platform.isBlank())?"UNKNOWN":s.platform;String n=(s.sessionName==null||s.sessionName.isBlank())?"Standalone accounted preamble":s.sessionName;return "STANDALONE PREAMBLE "+s.id+" - "+n+" • "+p+" • £100 research frame";}
        String base=displaySessionLabel(s);
        if(isEngineeringValidationSession(s)){
            String label=engineeringShortLabel(s);
            String identity=base+" (ENGINEERING UAT)";
            return label.isBlank()?identity:identity+" - "+label;
        }
        if(s!=null && s.sessionName!=null && !s.sessionName.isBlank()) return base+" - "+s.sessionName;
        return base;
    }
    private static String sessionArchitectureLabel(Session s){
        if(s!=null && s.architecture!=null && !s.architecture.isBlank()) return s.architecture.toUpperCase();
        return "FROZEN";
    }
    private static List<String> startCards(Hand h){return h.player.subList(0,Math.min(2,h.player.size()));}
    private static boolean sameState(Hand a,Hand b){List<String>x=new ArrayList<>(),y=new ArrayList<>();for(String c:startCards(a))x.add(key(c));for(String c:startCards(b))y.add(key(c));if(x.size()!=2||y.size()!=2)return false;Collections.sort(x);Collections.sort(y);return x.equals(y)&&key(a.dealerUp).equals(key(b.dealerUp));}
    private static String key(String c){if(c==null)return"?";String r=c.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim().toUpperCase();if(r.startsWith("10")||r.startsWith("J")||r.startsWith("Q")||r.startsWith("K"))return"10";if(r.startsWith("A"))return"A";Matcher m=Pattern.compile("([2-9])").matcher(r);return m.find()?m.group(1):"?";}
    private static String cardBucketMarker(String raw){
        if(raw==null)return ""; String r=openingRank(raw);
        if(r.matches("2|3|4|5|6"))return "↓";
        if(r.matches("7|8|9"))return "–";
        if(r.matches("10|J|Q|K|A"))return "↑";
        return "";
    }
    private static Color cardBucketColor(String marker){
        if("↓".equals(marker))return new Color(205,55,55);
        if("↑".equals(marker))return new Color(45,145,65);
        return new Color(70,95,125);
    }
    private static void drawBucketMarker(Graphics2D g,String raw,int x,int y,int fontSize){
        String m=cardBucketMarker(raw); if(m.isEmpty())return;
        g.setColor(cardBucketColor(m));g.setFont(new Font("SansSerif",Font.BOLD,fontSize));
        FontMetrics fm=g.getFontMetrics();g.drawString(m,x-fm.stringWidth(m),y);
    }

    private static class MiniCard extends JComponent{String r;boolean hole;MiniCard(String r){this(r,false);}MiniCard(String r,boolean hole){this.r=r;this.hole=hole;setPreferredSize(new Dimension(36,50));}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setColor(Color.WHITE);g.fillRoundRect(1,1,33,47,5,5);if(hole){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(3.0f));g.drawRoundRect(1,1,33,47,5,5);}else{g.setColor(Color.GRAY);g.drawRoundRect(1,1,33,47,5,5);}String s="";for(String z:new String[]{"♥","♦","♣","♠"})if(r.contains(z)){s=z;break;}String a=r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();g.setColor((s.equals("♥")||s.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,12));g.drawString(a,4,15);g.setFont(new Font("Serif",0,16));g.drawString(s.isEmpty()?"?":s,4,34);drawBucketMarker(g,r,31,13,12);g.dispose();}}
    private static class HoleReplayCard extends JComponent{String r;boolean back;HoleReplayCard(String r,boolean back){this.r=r;this.back=back;setPreferredSize(new Dimension(36,50));setMinimumSize(getPreferredSize());}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);if(back){g.setColor(new Color(24,62,105));g.fillRoundRect(2,2,31,45,6,6);g.setColor(Color.WHITE);g.drawRoundRect(2,2,31,45,6,6);for(int y=8;y<41;y+=7)for(int x=7;x<29;x+=7)g.drawOval(x,y,2,2);g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(3.0f));g.drawRoundRect(1,1,33,47,6,6);g.dispose();return;}g.setColor(Color.WHITE);g.fillRoundRect(1,1,33,47,5,5);g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(3.0f));g.drawRoundRect(1,1,33,47,5,5);String suit="";for(String z:new String[]{"♥","♦","♣","♠"})if(r!=null&&r.contains(z)){suit=z;break;}String a=r==null?"?":r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();g.setColor((suit.equals("♥")||suit.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,12));g.drawString(a,4,15);g.setFont(new Font("Serif",Font.PLAIN,16));g.drawString(suit.isEmpty()?"?":suit,4,34);drawBucketMarker(g,r,31,13,12);g.dispose();}}
    private static class StreamCard extends JComponent{String r;boolean back,hole;StreamCard(String r,boolean back){this(r,back,false);}StreamCard(String r,boolean back,boolean hole){this.r=r;this.back=back;this.hole=hole;setPreferredSize(new Dimension(26,38));setMinimumSize(getPreferredSize());}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);if(back){g.setColor(new Color(24,62,105));g.fillRoundRect(2,2,21,33,5,5);g.setColor(Color.WHITE);g.drawRoundRect(2,2,21,33,5,5);for(int y=7;y<31;y+=6)for(int x=5;x<21;x+=6)g.drawOval(x,y,2,2);if(hole){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(2.2f));g.drawRoundRect(1,1,23,35,5,5);}g.dispose();return;}g.setColor(Color.WHITE);g.fillRoundRect(2,2,21,33,5,5);g.setColor(new Color(120,125,130));g.drawRoundRect(2,2,21,33,5,5);String suit="";for(String z:new String[]{"♥","♦","♣","♠"})if(r!=null&&r.contains(z)){suit=z;break;}String a=r==null?"?":r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();g.setColor((suit.equals("♥")||suit.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,9));g.drawString(a,3,12);g.setFont(new Font("Serif",Font.PLAIN,12));g.drawString(suit.isEmpty()?"?":suit,3,27);drawBucketMarker(g,r,22,11,9);if(hole){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(2.2f));g.drawRoundRect(1,1,23,35,5,5);}g.dispose();}}
    private static class TinyCard extends JComponent{String r;boolean hole;TinyCard(String r){this(r,false);}TinyCard(String r,boolean hole){this.r=r;this.hole=hole;setPreferredSize(new Dimension(36,50));setMinimumSize(getPreferredSize());}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);g.setColor(Color.WHITE);g.fillRoundRect(2,2,31,45,5,5);g.setColor(new Color(120,125,130));g.drawRoundRect(2,2,31,45,5,5);String s="";for(String z:new String[]{"♥","♦","♣","♠"})if(r!=null&&r.contains(z)){s=z;break;}String a=r==null?"?":r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();if(a.contains("/"))a=a.substring(0,a.indexOf('/'))+"?";if(a.contains("UNKNOWN")||a.equals("+"))a="?";g.setColor((s.equals("♥")||s.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,12));g.drawString(a,4,15);g.setFont(new Font("Serif",Font.PLAIN,16));g.drawString(s.isEmpty()?"?":s,4,34);drawBucketMarker(g,r,31,13,12);if(hole){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(2.4f));g.drawRoundRect(1,1,33,47,5,5);}g.dispose();}}

    private static class CardPanel extends JComponent {
        final String raw; final boolean back; final boolean hole; final boolean invalidPostTerminal;
        CardPanel(String raw,boolean back){this(raw,back,false,false);}
        CardPanel(String raw,boolean back,boolean hole){this(raw,back,hole,false);}
        CardPanel(String raw,boolean back,boolean hole,boolean invalidPostTerminal){this.raw=raw==null?"?":raw.trim();this.back=back;this.hole=hole;this.invalidPostTerminal=invalidPostTerminal;setPreferredSize(new Dimension(92,132));setMinimumSize(getPreferredSize());}
        protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth()-2,h=getHeight()-2; Shape rr=new RoundRectangle2D.Double(1,1,w-1,h-1,13,13);
            if(back){
                g.setColor(new Color(245,245,245)); g.fill(rr);
                g.setColor(new Color(22,72,125)); g.setStroke(new BasicStroke(2f)); g.draw(rr);
                Shape inner=new RoundRectangle2D.Double(7,7,w-13,h-13,10,10);
                g.setColor(new Color(25,91,155)); g.fill(inner);
                g.setColor(new Color(220,235,248)); g.setStroke(new BasicStroke(1f));
                for(int y=14;y<h-10;y+=12) for(int x=14;x<w-10;x+=12) g.drawOval(x,y,4,4);
                g.dispose(); return;
            }
            g.setColor(Color.WHITE); g.fill(rr); g.setColor(new Color(30,35,40)); g.setStroke(new BasicStroke(1.5f)); g.draw(rr);
            String s=raw;
            boolean knownRankUnknownSuit=s.matches("(?i)(A|[2-9]|10|J|Q|K)[U?]"); boolean unknown=s.contains("UNKNOWN")||s.equals("+")||(s.contains("?")&&!knownRankUnknownSuit);
            if(unknown){g.setColor(new Color(90,90,90));g.setFont(new Font("SansSerif",Font.BOLD,34));drawCentered(g,"?",w/2,h/2+10);g.setFont(new Font("SansSerif",Font.PLAIN,10));drawCentered(g,"UNKNOWN",w/2,h-14);g.dispose();return;}
            String suit=""; for(String x:new String[]{"♥","♦","♣","♠"}) if(s.contains(x)){suit=x;break;}
            String rank=s.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();
            // v15.10.138-UAT: retained historical evidence may encode a known rank with
            // unresolved suit as e.g. 4U / 10U.  U is an evidence marker, not part of
            // the card rank; keep the rank and render the suit as unknown (?).
            if(suit.isEmpty() && rank.matches("(?i)(A|[2-9]|10|J|Q|K)[U?]")) rank=rank.substring(0,rank.length()-1);
            if(rank.contains("/")) rank=rank.substring(0,rank.indexOf('/'))+"?";
            Color c=(suit.equals("♥")||suit.equals("♦"))?new Color(190,35,45):new Color(25,30,35); g.setColor(c);
            g.setFont(new Font("Serif",Font.BOLD,26)); g.drawString(rank,10,31); g.setFont(new Font("Serif",Font.PLAIN,25)); g.drawString(suit.isEmpty()?"?":suit,10,58);
            g.setFont(new Font("Serif",Font.BOLD,48)); drawCentered(g,suit.isEmpty()?"?":suit,w/2,h/2+21); drawBucketMarker(g,raw,w-8,27,21); if(hole){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(3.2f));g.draw(rr);}
            if(invalidPostTerminal){g.setColor(new Color(190,35,45));g.setStroke(new BasicStroke(4.0f));g.draw(rr);g.setFont(new Font("SansSerif",Font.BOLD,10));drawCentered(g,"INVALID",w/2,h-8);}
            g.dispose();
        }
        private void drawCentered(Graphics2D g,String s,int x,int y){FontMetrics fm=g.getFontMetrics();g.drawString(s,x-fm.stringWidth(s)/2,y);}
    }


    private static final double EXCEPTIONAL_REPLAY_GAP = 50.0;
    private static class ControllerOutcome { String name; double bank; ControllerOutcome(String n,double b){name=n;bank=b;} }
    private Double frozenFinalForGate(Session s){
        if(s==null||s.hands.isEmpty())return null;
        if(!isHybridSession(s))return s.hands.get(s.hands.size()-1).researchEnd();
        try{List<CompareHand> f=buildFrozenTrace(comparisonEvidenceText(s.id),s.id);if(!f.isEmpty())return f.get(f.size()-1).after;}catch(Exception ignored){}
        return null;
    }
    private Double fixedFinalForGate(Session s){
        if(s==null)return null;try{List<CompareHand> c=buildCasualTrace(comparisonEvidenceText(s.id),s.id);if(!c.isEmpty()&&isSourceVerifiedCasual(s.id))return c.get(c.size()-1).after;}catch(Exception ignored){}return null;
    }
    private Double hybridFinalForGate(Session s){return s!=null&&isHybridSession(s)&&!s.hands.isEmpty()?s.hands.get(s.hands.size()-1).researchEnd():null;}
    private Double statFinalForGate(Session s){StatWatchView v=s==null?null:parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);if(v==null||Double.isNaN(v.chosenBank)||"SOURCE_EXHAUSTED".equals(v.chosenTermination))return null;return v.chosenBank;}
    private Double ultimateFinalForGate(Session s){UltimateAIView v=s==null?null:parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);return v!=null&&v.aiComplete&&!Double.isNaN(v.aiBank)?v.aiBank:null;}
    private boolean exceptionalAgainstOthers(String candidate,double bank,List<ControllerOutcome> all){
        double hi=-Double.MAX_VALUE,lo=Double.MAX_VALUE;int n=0;for(ControllerOutcome x:all){if(x.name.equals(candidate))continue;hi=Math.max(hi,x.bank);lo=Math.min(lo,x.bank);n++;}return n>0&&(bank-hi>=EXCEPTIONAL_REPLAY_GAP-0.0001||lo-bank>=EXCEPTIONAL_REPLAY_GAP-0.0001);
    }
    private String exceptionalReplayTarget(Session s){
        List<ControllerOutcome>a=new ArrayList<>();Double z;
        if((z=frozenFinalForGate(s))!=null)a.add(new ControllerOutcome("Frozen",z));
        if((z=fixedFinalForGate(s))!=null)a.add(new ControllerOutcome("Fixed-Policy Casual",z));
        if((z=hybridFinalForGate(s))!=null)a.add(new ControllerOutcome("Hybrid",z));
        if((z=statFinalForGate(s))!=null)a.add(new ControllerOutcome("Stat-Watching Casual",z));
        if((z=ultimateFinalForGate(s))!=null)a.add(new ControllerOutcome("Ultimate AI",z));
        ControllerOutcome best=null;double bestMargin=-1;
        for(ControllerOutcome c:a){if(!(c.name.equals("Ultimate AI")||c.name.equals("Stat-Watching Casual")||c.name.equals("Fixed-Policy Casual")))continue;if(!exceptionalAgainstOthers(c.name,c.bank,a))continue;double hi=-Double.MAX_VALUE,lo=Double.MAX_VALUE;for(ControllerOutcome o:a)if(o!=c){hi=Math.max(hi,o.bank);lo=Math.min(lo,o.bank);}double margin=Math.max(c.bank-hi,lo-c.bank);if(margin>bestMargin){best=c;bestMargin=margin;}}
        return best==null?null:best.name;
    }
    private boolean hybridDivergesFromFrozen(Session s){
        if(s==null||!isHybridSession(s)||s.hands.isEmpty())return false;
        for(Hand h:s.hands){
            if(Math.abs(parseMoney(h.wager)-parseMoney(h.refWager))>=0.001)return true;
            if(Boolean.FALSE.equals(h.frozenFollowed))return true;
        }
        return false;
    }
    // v15.10.257: one authoritative preflight is used by BOTH the selector and opener.
    // An advertised comparison is therefore a promise that the same retained-evidence
    // gate used at open time has already passed.  No evidence rule is weakened.
    private boolean casualComparisonOpenable(Session s){
        if(s==null)return false;
        boolean complete=isSourceVerifiedCasual(s.id), partial=isSourceSupportedPartialCasual(s.id);
        if(!complete&&!partial)return false;
        try{
            String evidenceText=comparisonEvidenceText(s.id);
            List<CompareHand> casual=buildCasualTrace(evidenceText,s.id);
            if(casual.isEmpty())return false;
            if(hasHybridFormalStartEvidence(s.id)){
                List<CompareHand> frozen=buildFrozenTrace(evidenceText,s.id);
                if(frozen.isEmpty())return false;
                validateFrozenTraceAgainstHybridEvidence(evidenceText,s.id,frozen);
            }
            return true;
        }catch(Exception ex){return false;}
    }
    private boolean hybridComparisonOpenable(Session s){
        if(s==null||!isHybridSession(s)||!hasHybridFormalStartEvidence(s.id))return false;
        try{
            String evidenceText=comparisonEvidenceText(s.id);
            List<CompareHand> frozen=buildFrozenTrace(evidenceText,s.id);
            if(frozen.isEmpty())return false;
            validateFrozenTraceAgainstHybridEvidence(evidenceText,s.id,frozen);
            return true;
        }catch(Exception ex){return false;}
    }
    private boolean statComparisonOpenable(Session s){
        if(s==null)return false;
        try{
            List<CompareHand> sw=buildStatWatchingTrace(controllerTraceEvidenceText(s.id),s.id);
            if(!sw.isEmpty())return true;
        }catch(Exception ignored){}
        StatWatchView v=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);
        // v15.10.259: a retained source-bounded bankroll path remains valid replay
        // evidence even when chronology exhaustion prevents a completed final outcome.
        // Availability is about replayability, not completion. Never infer beyond PATH_CHOSEN.
        return v!=null&&v.chosenPath!=null&&v.chosenPath.size()>1;
    }
    private boolean ultimateComparisonOpenable(Session s){
        if(s==null)return false;
        try{if(!buildUltimateTrace(ultimateAIEvidenceText(s.id),s.id).isEmpty())return true;}catch(Exception ignored){}
        UltimateAIView v=parseUltimateAIBlock(ultimateAIEvidenceText(s.id),s.id,"S"+s.publicationNumber);
        // Same source-bounded rule for Ultimate: PATH_AI may be replayed to the last
        // retained point even when the controller is explicitly NOT SOURCE-COMPLETE.
        return v!=null&&v.path!=null&&v.path.size()>1;
    }
    private void addCompareTargetIfOpenable(List<String> targets,String name,boolean openable){
        if(openable&&!targets.contains(name))targets.add(name);
    }
    private boolean preambleStartCasualEvidenceOpenable(Session s){
        if(s==null||loadedOutputText==null)return false;
        String sid=Pattern.quote(s.id);
        return Pattern.compile("(?s)PREAMBLE-START COUNTERFACTUAL \\| SESSION "+sid+".*?Casual from preamble start: hands \\d+ \\| final £[0-9.]+",Pattern.CASE_INSENSITIVE).matcher(loadedOutputText).find();
    }
    private void openPreambleStartCasualEvidence(){
        if(currentSession==null)return; String sid=Pattern.quote(currentSession.id);
        Matcher m=Pattern.compile("(?s)PREAMBLE-START COUNTERFACTUAL \\| SESSION "+sid+".*?Frozen from preamble start: hands (\\d+) \\| final £([0-9.]+).*?cards consumed (\\d+).*?termination ([A-Z_]+).*?Casual from preamble start: hands (\\d+) \\| final £([0-9.]+).*?cards consumed (\\d+).*?termination ([A-Z_]+)",Pattern.CASE_INSENSITIVE).matcher(loadedOutputText);
        if(!m.find()){JOptionPane.showMessageDialog(this,"Preamble-start Fixed-Policy Casual evidence is not available without inference.","Replay unavailable",JOptionPane.WARNING_MESSAGE);return;}
        JPanel p=new JPanel();p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));p.setBorder(new EmptyBorder(14,18,14,18));
        JLabel h=new JLabel("PREAMBLE-START COUNTERFACTUAL — SOURCE-BOUNDED EVIDENCE");h.setFont(new Font("SansSerif",Font.BOLD,16));h.setAlignmentX(Component.LEFT_ALIGNMENT);p.add(h);p.add(Box.createVerticalStrut(10));
        p.add(new JLabel("Frozen Reference: "+m.group(1)+" hands • last supported £"+m.group(2)+" • "+m.group(3)+" cards consumed • "+m.group(4)));
        p.add(Box.createVerticalStrut(5));
        p.add(new JLabel("Fixed-Policy Casual: "+m.group(5)+" hands • last supported £"+m.group(6)+" • "+m.group(7)+" cards consumed • "+m.group(8)));
        p.add(Box.createVerticalStrut(10));
        JLabel n=new JLabel("<html>Per-hand retained path is not present for this controller family, so no cards/actions are invented.<br>This window exposes the legitimate retained preamble-start journey evidence without promoting source exhaustion to completion.</html>");n.setFont(new Font("SansSerif",Font.PLAIN,11));p.add(n);
        JOptionPane.showMessageDialog(this,p,"Fixed-Policy Casual — Preamble Start",JOptionPane.INFORMATION_MESSAGE);
    }

    private void refreshCompareTargets(Session s){
        String prior=String.valueOf(compareTargetBox.getSelectedItem());
        compareTargetBox.removeAllItems();
        List<String> targets=new ArrayList<>();
        if(s!=null && s.standalonePreamble){
            targets.add("Observed — Standalone Preamble");
            for(String t:targets)compareTargetBox.addItem(t);
            compareTargetBox.setEnabled(true); compareButton.setEnabled(true);
            compareButton.setToolTipText("Open Frozen baseline and observed standalone preamble side by side from the same captured stream");
            return;
        }

        // Preserve the historical preference order, but never advertise a target that
        // fails its real retained-evidence preflight.  Hybrid sameness remains valid.
        if(isHybridSession(s)) addCompareTargetIfOpenable(targets,"Hybrid",hybridComparisonOpenable(s));
        else addCompareTargetIfOpenable(targets,"Fixed-Policy Casual",casualComparisonOpenable(s));

        String exceptional=exceptionalReplayTarget(s);
        if("Fixed-Policy Casual".equals(exceptional)) addCompareTargetIfOpenable(targets,exceptional,casualComparisonOpenable(s));
        else if("Stat-Watching Casual".equals(exceptional)) addCompareTargetIfOpenable(targets,exceptional,statComparisonOpenable(s));
        else if("Ultimate AI".equals(exceptional)) addCompareTargetIfOpenable(targets,exceptional,ultimateComparisonOpenable(s));

        addCompareTargetIfOpenable(targets,"Fixed-Policy Casual",casualComparisonOpenable(s));
        addCompareTargetIfOpenable(targets,"Fixed-Policy Casual — Preamble Start",preambleStartCasualEvidenceOpenable(s));
        addCompareTargetIfOpenable(targets,"Stat-Watching Casual",statComparisonOpenable(s));
        addCompareTargetIfOpenable(targets,"Ultimate AI",ultimateComparisonOpenable(s));
        if(isHybridSession(s)) addCompareTargetIfOpenable(targets,"Hybrid",hybridComparisonOpenable(s));

        for(String t:targets)compareTargetBox.addItem(t);
        compareTargetBox.setEnabled(!targets.isEmpty());
        compareButton.setEnabled(!targets.isEmpty());
        for(int i=0;i<compareTargetBox.getItemCount();i++)if(prior.equals(compareTargetBox.getItemAt(i))){compareTargetBox.setSelectedIndex(i);break;}
    }
    private List<Double> frozenPathForEvidenceReplay(Session s){List<Double> p=new ArrayList<>();p.add(100.0);if(s==null)return p;if(!isHybridSession(s)){for(Hand h:s.hands)p.add(h.researchEnd());return p;}try{for(CompareHand h:buildFrozenTrace(comparisonEvidenceText(s.id),s.id))p.add(h.after);}catch(Exception ignored){}return p;}
    private void openEvidencePathComparison(String target){
        if(currentSession==null)return;List<Double> targetPath=new ArrayList<>();String evidence="";
        if("Ultimate AI".equals(target)){UltimateAIView v=parseUltimateAIBlock(ultimateAIEvidenceText(currentSession.id),currentSession.id,"S"+currentSession.publicationNumber);if(v==null||v.path==null||v.path.size()<2)return;targetPath.addAll(v.path);evidence=v.aiComplete?"SOURCE-COMPLETE Ultimate AI deterministic counterfactual":"SOURCE-BOUNDED Ultimate AI deterministic counterfactual • "+v.termination+" • final outcome N/A";}
        else {StatWatchView v=parseStatWatchBlock(loadedOutputText,currentSession.id,"S"+currentSession.publicationNumber);if(v==null||v.chosenPath==null||v.chosenPath.size()<2)return;targetPath.addAll(v.chosenPath);boolean complete=!"SOURCE_EXHAUSTED".equals(v.chosenTermination);evidence=complete?"source-complete Stat-Watching Casual exploratory counterfactual":"SOURCE-BOUNDED Stat-Watching Casual exploratory counterfactual • SOURCE_EXHAUSTED • final outcome N/A";}
        new EvidencePathCompareWindow(currentSession,target,frozenPathForEvidenceReplay(currentSession),targetPath,evidence).setVisible(true);
    }
    private class EvidencePathCompareWindow extends JFrame{
        final List<Double> f,t;final JLabel hand=new JLabel(),left=new JLabel(),right=new JLabel(),gap=new JLabel(),note=new JLabel();int i=0;
        EvidencePathCompareWindow(Session s,String target,List<Double> frozen,List<Double> path,String ev){super("Frozen vs "+target+" — "+sessionDisplayTitle(s));f=frozen;t=path;setDefaultCloseOperation(DISPOSE_ON_CLOSE);setSize(1040,500);setLocationRelativeTo(BlackjackSessionReplay.this);JPanel root=new JPanel(new BorderLayout(10,10));root.setBorder(new EmptyBorder(18,18,18,18));boolean preambleUltimate="Ultimate AI".equals(target)&&s!=null&&s.hadPreamble;String titleText=preambleUltimate?"ENTRY-POINT COUNTERFACTUAL — FROZEN FORMAL START vs ULTIMATE AI PREAMBLE START":"FROZEN BASELINE  |  "+target.toUpperCase(Locale.ROOT)+" — SELECTIVE REPLAY";JLabel title=new JLabel(titleText,SwingConstants.CENTER);title.setFont(new Font("SansSerif",Font.BOLD,20));root.add(title,BorderLayout.NORTH);JPanel mid=new JPanel(new GridLayout(1,2,14,0));mid.add(pathCard("FROZEN — BASELINE",left));mid.add(pathCard(target.toUpperCase(Locale.ROOT),right));root.add(mid,BorderLayout.CENTER);JPanel south=new JPanel();south.setLayout(new BoxLayout(south,BoxLayout.Y_AXIS));hand.setAlignmentX(Component.CENTER_ALIGNMENT);gap.setAlignmentX(Component.CENTER_ALIGNMENT);note.setAlignmentX(Component.CENTER_ALIGNMENT);String positionNote=preambleUltimate?"<br><b>POSITION: 65 cards earlier than Frozen formal start at entry.</b><br>Per-hand card-position decrement is unavailable in this bankroll-path-only evidence and is therefore not inferred.":"";note.setText("<html><center>"+ev+positionNote+"<br>Bankroll-path evidence only — cards/actions are not inferred where the retained controller evidence does not contain them.</center></html>");south.add(hand);south.add(gap);south.add(note);JPanel buttons=new JPanel();JButton prev=new JButton("◀ PREVIOUS"),next=new JButton("NEXT ▶");prev.addActionListener(e->{if(i>0)i--;showPoint();});next.addActionListener(e->{if(i<Math.max(f.size(),t.size())-1)i++;showPoint();});buttons.add(prev);buttons.add(next);south.add(buttons);root.add(south,BorderLayout.SOUTH);setContentPane(root);showPoint();}
        JPanel pathCard(String name,JLabel value){JPanel p=new JPanel(new BorderLayout());p.setBorder(new CompoundBorder(new LineBorder(new Color(185,195,205)),new EmptyBorder(18,18,18,18)));JLabel n=new JLabel(name,SwingConstants.CENTER);n.setFont(new Font("SansSerif",Font.BOLD,16));value.setHorizontalAlignment(SwingConstants.CENTER);value.setFont(new Font("SansSerif",Font.BOLD,42));p.add(n,BorderLayout.NORTH);p.add(value,BorderLayout.CENTER);return p;}
        void showPoint(){String fv=i<f.size()?"£"+money(f.get(i)):"—",tv=i<t.size()?"£"+money(t.get(i)):"—";left.setText(fv);right.setText(tv);hand.setText("HAND "+i+"  •  retained bankroll path");if(i<f.size()&&i<t.size()){double d=t.get(i)-f.get(i);gap.setText("Difference vs Frozen: "+(d>=0?"+":"-")+"£"+money(Math.abs(d)));}else if(i>=f.size()&&i<t.size())gap.setText(i==t.size()-1?"Frozen journey ended • Ultimate AI last-supported bankroll £"+money(t.get(i))+" • FINAL BANKROLL N/A":"Frozen journey ended • Ultimate AI retained bankroll path continues");else gap.setText("No mutually supported bankroll point at this hand");}
    }

    private void updateCompareAvailability(){
        String target=String.valueOf(compareTargetBox.getSelectedItem());
        if(isSemanticQuarantined(currentSession)){
            compareButton.setEnabled(false);
            compareButton.setToolTipText("Quarantined session — comparison/research authority disabled; forensic replay only");
            return;
        }
        if(currentSession!=null && currentSession.standalonePreamble && "Observed — Standalone Preamble".equalsIgnoreCase(target)){
            boolean ok=false; try{ok=!buildFrozenTrace(comparisonEvidenceText(currentSession.id),currentSession.id).isEmpty();}catch(Exception ignored){}
            compareButton.setEnabled(ok); compareButton.setToolTipText(ok?"Open Frozen baseline and observed standalone preamble side by side":"Frozen reconstruction unavailable without sufficient captured chronology"); return;
        }
        if("Fixed-Policy Casual — Preamble Start".equalsIgnoreCase(target)){boolean ok=preambleStartCasualEvidenceOpenable(currentSession);compareButton.setEnabled(ok);compareButton.setToolTipText(ok?"Open retained preamble-start Fixed-Policy Casual source-bounded evidence":"Preamble-start Casual evidence unavailable");return;}
        if("Ultimate AI".equalsIgnoreCase(target)){boolean ok=ultimateComparisonOpenable(currentSession);compareButton.setEnabled(ok);compareButton.setToolTipText(ok?"Open retained Ultimate AI replay at the richest source-supported evidence level":"Ultimate AI replay unavailable: no retained source-supported trace or bankroll path");return;}
        if("Stat-Watching Casual".equalsIgnoreCase(target)){boolean ok=statComparisonOpenable(currentSession);compareButton.setEnabled(ok);compareButton.setToolTipText(ok?"Open retained Stat-Watching replay at the richest source-supported evidence level":"Stat-Watching replay unavailable: no retained source-supported trace or bankroll path");return;}
        if("Hybrid".equalsIgnoreCase(target)){
            boolean ok=currentSession!=null && isHybridSession(currentSession) && hasHybridFormalStartEvidence(currentSession.id);
            compareButton.setEnabled(ok);
            compareButton.setToolTipText(ok?
                    "Open Frozen baseline and observed Hybrid journey side by side from the same formal captured source":
                    "Hybrid comparison requires a selected observed HYBRID session with retained formal-start counterfactual evidence");
            return;
        }
        boolean complete=currentSession!=null && isSourceVerifiedCasual(currentSession.id);
        boolean partial=currentSession!=null && isSourceSupportedPartialCasual(currentSession.id);
        boolean ok=complete||partial;
        compareButton.setEnabled(ok);
        compareButton.setToolTipText(complete?"Open source-verified Frozen and Casual journeys side by side":
                partial?"Open source-supported Casual trace; final Casual outcome remains N/A because the captured source was exhausted":
                "Casual hand-by-hand comparison is unavailable without sufficient source-supported chronology");
    }
    private boolean isHybridSession(Session s){
        return s!=null && s.architecture!=null && s.architecture.toUpperCase(Locale.ROOT).contains("HYBRID");
    }
    private boolean hasHybridFormalStartEvidence(String sid){
        if(loadedOutputText==null||sid==null)return false;
        String start="================ HYBRID FORMAL-START COUNTERFACTUAL | SESSION "+sid+" ================";
        int a=loadedOutputText.lastIndexOf(start); if(a<0)return false;
        int b=loadedOutputText.indexOf("================ END HYBRID FORMAL-START COUNTERFACTUAL | SESSION "+sid+" ================",a);
        if(b<0)b=loadedOutputText.length();
        String block=loadedOutputText.substring(a,b);
        return block.contains("Frozen from formal start:") && block.contains("observed Hybrid journey remains separate");
    }
    private boolean isSourceVerifiedCasual(String sid){
        if(loadedOutputText==null||sid==null)return false;
        // v15.10.83-UAT: retain the established pre-Hybrid Casual evidence gate.
        // Older sessions record their reconstruction status inside the main SESSION
        // block; newer Hybrid analysis may also carry a dedicated reconstruction block.
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a>=0){
            int b=loadedOutputText.indexOf("================ SESSION ",a+20); if(b<0)b=loadedOutputText.length();
            String block=loadedOutputText.substring(a,b);
            if(block.contains("Casual reconstructed:") && block.contains("RECONSTRUCTION STATUS: SOURCE-VERIFIED")) return true;
        }
        int r=loadedOutputText.indexOf("================ OBSERVED-SOURCE RECONSTRUCTION | SESSION "+sid+" ================");
        if(r>=0){int b=loadedOutputText.indexOf("================ END OBSERVED-SOURCE RECONSTRUCTION",r);if(b<0)b=loadedOutputText.length();String block=loadedOutputText.substring(r,b);return block.contains("Casual reconstructed:")&&block.contains("RECONSTRUCTION STATUS: SOURCE-VERIFIED");}
        return false;
    }

    private boolean isSourceSupportedPartialCasual(String sid){
        if(loadedOutputText==null||sid==null)return false;
        // Session 9 Hybrid formal-start evidence intentionally permits a bounded
        // source-exhausted Casual trace; only fully settled hands are displayed.
        String hs="================ HYBRID FORMAL-START COUNTERFACTUAL | SESSION "+sid+" ================";
        if(loadedOutputText.indexOf(hs)>=0){
            try{return !buildCasualTrace(loadedOutputText,sid).isEmpty();}catch(Exception ex){return false;}
        }
        // Session 6 has the documented missing-card boundary already protected by observed().
        if(sid.contains("20260907_003015")){
            try{return !buildCasualTrace(loadedOutputText,sid).isEmpty();}catch(Exception ex){return false;}
        }
        // Established historical rule from the accepted Frozen-vs-Casual replay:
        // a clean arithmetic reconstruction that terminates only through source
        // exhaustion may still expose its fully-settled prefix.
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a<0)return false; int b=loadedOutputText.indexOf("================ SESSION ",a+20);if(b<0)b=loadedOutputText.length();String block=loadedOutputText.substring(a,b);
        boolean clean=Pattern.compile("(?i)Validation warnings/overrides encountered:\\s*0").matcher(block).find();
        boolean failed=block.contains("Casual completion FAIL") && block.contains("Arithmetic PASS");
        if(!clean||!failed)return false;
        try{return !buildCasualTrace(loadedOutputText,sid).isEmpty();}catch(Exception ex){}
        // v15.10.255: session-scoped dedicated chronology may legitimately support a
        // settled prefix even when the cumulative output block does not carry the old
        // reconstruction-status wording.  Never infer beyond the retained card source.
        try{return !buildCasualTrace(comparisonEvidenceText(sid),sid).isEmpty();}catch(Exception ex){return false;}
    }
    private void openSelectedComparison(){
        String target=String.valueOf(compareTargetBox.getSelectedItem());
        if("Observed — Standalone Preamble".equalsIgnoreCase(target)) openStandalonePreambleComparison();
        else if("Hybrid".equalsIgnoreCase(target)) openHybridComparison();
        else if("Fixed-Policy Casual — Preamble Start".equalsIgnoreCase(target)) openPreambleStartCasualEvidence();
        else if("Ultimate AI".equalsIgnoreCase(target)) openUltimateComparison();
        else if("Stat-Watching Casual".equalsIgnoreCase(target)) openStatWatchingComparison();
        else openCasualComparison();
    }
    private List<CompareHand> observedPreambleTrace(Session s){
        List<CompareHand> out=new ArrayList<>(); if(s==null)return out;
        try{CObserved o=observed(comparisonEvidenceText(s.id),s.id);
            for(Hand h:s.hands){
                CompareHand q=new CompareHand();q.number=h.number;q.before=h.researchStart();q.after=h.researchEnd();q.wager=parseMoney(h.wager);q.committed=parseMoney(h.committed);q.result=friendlyOutcome(h.result).substring(0,1);q.dealerUp=h.dealerUp;q.player=new ArrayList<>(h.player);q.actions=new ArrayList<>(h.actions);
                boolean terminalPlayer=h.playerBust||h.natural||isTwoCardNatural(h.player);
                q.dealer=new ArrayList<>();q.dealer.add(h.dealerUp);
                if(!h.dealerHidden.isEmpty())q.dealer.add(h.dealerHidden.get(0));
                if(!terminalPlayer&&h.dealerHidden.size()>1)q.dealer.addAll(h.dealerHidden.subList(1,h.dealerHidden.size()));
                List<CCard> cards=o.byHand.get(h.number);
                if(cards!=null){
                    Integer upSeq=null,holeSeq=null;List<Integer>drawSeq=new ArrayList<>();
                    for(CCard c:cards){String r=c.role==null?"":c.role.toUpperCase(Locale.ROOT);if(r.startsWith("PLAYER"))q.playerSeq.add(c.seq);else if(r.contains("DEALER UPCARD"))upSeq=c.seq;else if(r.contains("DEALER HOLE"))holeSeq=c.seq;else if(r.startsWith("DEALER"))drawSeq.add(c.seq);}
                    if(upSeq!=null)q.dealerSeq.add(upSeq);if(holeSeq!=null)q.dealerSeq.add(holeSeq);if(!terminalPlayer)q.dealerSeq.addAll(drawSeq);
                    // v15.10.337-UAT: renderer transport for source-authoritative observed splits.
                    // The detailed preamble parser already proved actual SPLIT and retained child cards;
                    // carry that structure (and exact source sequence badges) into CompareHand rather than
                    // collapsing the right pane back to the original pair.
                    populateObservedSplitCompareHand(q,h,cards);
                }
                out.add(q);
            }
        }catch(Exception ignored){} return out;
    }

    static void populateObservedSplitCompareHand(CompareHand q,Hand h,List<CCard> cards){
        if(q==null||h==null||!h.split||h.splitA==null||h.splitB==null||h.splitA.isEmpty()||h.splitB.isEmpty())return;
        q.branches.clear();q.branchSeq.clear();
        q.branches.add(new ArrayList<>(h.splitA));q.branches.add(new ArrayList<>(h.splitB));
        List<Integer>a=new ArrayList<>(),b=new ArrayList<>();
        CCard p1=null,p2=null;List<CCard>post=new ArrayList<>();
        if(cards!=null)for(CCard c:cards){String r=c.role==null?"":c.role.toUpperCase(Locale.ROOT);if(r.contains("PLAYER CARD 1"))p1=c;else if(r.contains("PLAYER CARD 2"))p2=c;else if(r.startsWith("PLAYER POST-DEAL"))post.add(c);}
        if(p1!=null)a.add(p1.seq);if(p2!=null)b.add(p2.seq);
        boolean[]used=new boolean[post.size()];
        appendObservedSplitSeq(a,h.splitA,1,post,used);appendObservedSplitSeq(b,h.splitB,1,post,used);
        q.branchSeq.add(a);q.branchSeq.add(b);
    }
    static void appendObservedSplitSeq(List<Integer> dest,List<String> branch,int from,List<CCard> post,boolean[]used){
        for(int j=from;j<branch.size();j++){
            String want=Parser.cardKey(branch.get(j));int hit=-1;
            for(int i=0;i<post.size();i++)if(!used[i]&&want.equals(Parser.cardKey(post.get(i).toString()))){hit=i;break;}
            if(hit>=0){used[hit]=true;dest.add(post.get(hit).seq);}
        }
    }

    private void openStandalonePreambleComparison(){
        if(currentSession==null||!currentSession.standalonePreamble)return;
        try{String evidence=comparisonEvidenceText(currentSession.id);List<CompareHand> frozen=buildFrozenTrace(evidence,currentSession.id);List<CompareHand> personal=observedPreambleTrace(currentSession);if(frozen.isEmpty()||personal.isEmpty())throw new IOException("Frozen or observed source-supported trace unavailable");new CompareWindow(currentSession,frozen,personal,Math.max(0,handIndex),true,"Observed — Standalone Preamble").setVisible(true);}
        catch(Exception ex){JOptionPane.showMessageDialog(this,"Frozen vs Personal standalone-preamble comparison could not be opened without inventing evidence:\n"+ex.getMessage(),"Comparison unavailable",JOptionPane.WARNING_MESSAGE);}
    }

    private void openCasualComparison(){
        if(currentSession==null)return;
        boolean complete=isSourceVerifiedCasual(currentSession.id);
        boolean partial=isSourceSupportedPartialCasual(currentSession.id);
        if(!complete&&!partial)return;
        try{
            String evidenceText=comparisonEvidenceText(currentSession.id);
            List<CompareHand> casual=buildCasualTrace(evidenceText,currentSession.id);
            if(casual.isEmpty())throw new IOException("No source-supported Fixed-Policy Casual hands reconstructed");
            List<CompareHand> formalFrozen=null;
            if(hasHybridFormalStartEvidence(currentSession.id)){
                formalFrozen=buildFrozenTrace(evidenceText,currentSession.id);
                if(formalFrozen.isEmpty())throw new IOException("No source-supported formal-start Frozen hands reconstructed");
                validateFrozenTraceAgainstHybridEvidence(evidenceText,currentSession.id,formalFrozen);
            }
            new CompareWindow(currentSession,formalFrozen,casual,Math.max(0,handIndex),complete).setVisible(true);
        }catch(Exception ex){JOptionPane.showMessageDialog(this,"Frozen vs Fixed-Policy Casual comparison could not be opened without inventing evidence:\n"+ex.getMessage(),"Comparison unavailable",JOptionPane.WARNING_MESSAGE);}
    }
    // v15.10.267-UAT: card-position badges are provenance, not controller output.
    // When an Ultimate trace is reconstructed from the same canonical playable chronology,
    // restore its captured-stream draw-order numbers only from an exact canonical-card match.
    // Fail closed hand-by-hand: no match means no badge; numbers are never inferred or renumbered.
    private void attachCanonicalSeqBadgesToUltimate(List<CompareHand> ai,String sid)throws Exception{
        if(ai==null||ai.isEmpty())return;
        List<CompareHand> canonical=buildFrozenTrace(comparisonEvidenceText(sid),sid);
        Map<Integer,CompareHand> byHand=new HashMap<>();for(CompareHand f:canonical)byHand.put(f.number,f);
        for(CompareHand q:ai){
            CompareHand f=byHand.get(q.number);if(f==null)continue;
            if(q.player.equals(f.player)&&q.dealer.equals(f.dealer)){q.playerSeq=new ArrayList<>(f.playerSeq);q.dealerSeq=new ArrayList<>(f.dealerSeq);}
            if(q.branches.size()==f.branches.size()){
                boolean exact=true;for(int i=0;i<q.branches.size();i++)if(!q.branches.get(i).equals(f.branches.get(i))){exact=false;break;}
                if(exact){q.branchSeq=new ArrayList<>();for(List<Integer>x:f.branchSeq)q.branchSeq.add(new ArrayList<>(x));}
            }
        }
    }

    private void openUltimateComparison(){
        if(currentSession==null)return;
        try{
            String aiText=ultimateAIEvidenceText(currentSession.id);
            List<CompareHand> ai=buildUltimateTrace(aiText,currentSession.id);
            if(ai.isEmpty()){openEvidencePathComparison("Ultimate AI");return;}
            // v15.10.253: Session-scoped card-detail transport.  The historical V9.2.61
            // reconstruction retained exact ranks/actions/bankroll but not suits.  Where every
            // displayed Ultimate hand has the same rank chronology, result and action sequence as
            // the canonical observed Frozen hand, transport the already-retained observed card
            // tokens (including suits) into the Ultimate display.  Fail closed hand-by-hand.
            // v15.10.263: the Session 14 corrected-chronology diagnostic AI_TRACE already
            // contains complete card detail from the canonical playable chronology.  Do not
            // attempt to join it to the legacy V9.2.61 SUIT_TRACE, whose pointer deliberately
            // differs from H11 (#58 legacy reuse).  Other historical sessions retain the
            // version-matched suit-transport gate.
            if (!"20260920_003052".equals(currentSession.id))
                transportObservedCardDetailToUltimate(aiText,currentSession.id,ai);
            // v15.10.267: Session 14 canonical diagnostic AI_TRACE retains cards but not source sequence IDs.
            // Reattach blue badges strictly from an exact same-card canonical reconstruction.
            if ("20260920_003052".equals(currentSession.id))
                attachCanonicalSeqBadgesToUltimate(ai,currentSession.id);
            // Ultimate is compared to the canonical observed Frozen journey held by currentSession.
            // Do not substitute a second formal-start Frozen counterfactual on the left.
            UltimateAIView uv=parseUltimateAIBlock(aiText,currentSession.id,"S"+currentSession.publicationNumber);
            new CompareWindow(currentSession,null,ai,Math.max(0,handIndex),true,"Ultimate AI",null,uv).setVisible(true);
        }catch(Exception ex){JOptionPane.showMessageDialog(this,"Frozen vs Ultimate AI rich replay could not be opened without inventing evidence:\n"+ex.getMessage(),"Comparison unavailable",JOptionPane.WARNING_MESSAGE);}
    }
    private static String ultimateRankOnly(String c){
        if(c==null)return "?";
        String x=c.trim().toUpperCase(Locale.ROOT);
        x=x.replaceAll("[♠♥♦♣SHDC?]+$","");
        return x.isEmpty()?"?":x;
    }
    private static boolean sameRanks(List<String>a,List<String>b){
        if(a==null||b==null||a.size()!=b.size())return false;
        for(int i=0;i<a.size();i++)if(!ultimateRankOnly(a.get(i)).equals(ultimateRankOnly(b.get(i))))return false;
        return true;
    }
    private static void transportObservedCardDetailToUltimate(String evidenceText,String sid,List<CompareHand> ai)throws Exception{
        if(evidenceText==null||sid==null||ai==null)return;
        // v15.10.254: use only the version-matched historical suit trace generated from the
        // exact Card objects consumed by V9.2.61.  Bankroll/wager/exit remain sourced from the
        // authoritative historical AI_TRACE.  Join is hand number + complete rank chronology.
        Map<Integer,CompareHand> donors=new HashMap<>();
        for(String line:evidenceText.split("\\R")){if(!line.startsWith("SUIT_TRACE | H"))continue;CompareHand d=new CompareHand();for(String raw:line.split("\\|",-1)){String t=raw.trim();if(t.startsWith("SUIT_TRACE ")){Matcher m=Pattern.compile("H(\\d+)").matcher(t);if(m.find())d.number=Integer.parseInt(m.group(1));}else if(t.matches("H\\d+")){d.number=Integer.parseInt(t.substring(1));}else if(t.startsWith("player ")){String v=t.substring(7).trim();if(!v.isEmpty())d.player.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("dealer ")){String v=t.substring(7).trim();if(!v.isEmpty())d.dealer.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("dealerUp "))d.dealerUp=t.substring(9).trim();}if(d.number>0)donors.put(d.number,d);}
        int joined=0;for(CompareHand q:ai){CompareHand d=donors.get(q.number);if(d==null)continue;if(!sameRanks(q.player,d.player)||!sameRanks(q.dealer,d.dealer))continue;q.player=new ArrayList<>(d.player);q.dealer=new ArrayList<>(d.dealer);q.dealerUp=d.dealerUp;if(q.branches.size()==1)q.branches.set(0,new ArrayList<>(d.player));joined++;}
        if("20260920_003052".equals(sid)&&joined!=ai.size())throw new IOException("Ultimate historical card-detail join incomplete: "+joined+"/"+ai.size());
    }

    static List<CompareHand> buildUltimateTrace(String text,String sid)throws IOException{
        List<CompareHand> out=new ArrayList<>();if(text==null)return out;
        String start="================ ULTIMATE AI | SESSION "+sid+" ================";
        int first=text.indexOf(start); if(first<0)return out;
        int a=text.lastIndexOf(start);
        // v15.10.262: Session 14 canonical chronology repair.  The historical V9.2.61
        // rich reconstruction re-used source #58 (a post-bust provenance-only card) at H11.
        // Never display that legacy pointer path as the canonical playable comparison.
        // Select the retained corrected-chronology diagnostic block (25 hands / £32.50)
        // when present.  The declared Ultimate-AI-V1 policy family is unchanged; corrected chronology
        // changes the visible prior-hand state and therefore can legitimately change whether
        // its evidence gates fire.  Historical £250 publication authority remains immutable.
        if ("20260920_003052".equals(sid)) {
            int scan=first;
            while(scan>=0){
                int end=text.indexOf("================ END ULTIMATE AI | SESSION "+sid+" ================",scan);
                if(end<0)end=text.length();
                String candidate=text.substring(scan,end);
                if(Pattern.compile("Ultimate AI: hands\\s+25\\s+\\|\\s+bankroll\\s+£?32\\.50",Pattern.CASE_INSENSITIVE).matcher(candidate).find()){a=scan;break;}
                scan=text.indexOf(start,end);
            }
        }
        int b=text.indexOf("================ END ULTIMATE AI | SESSION "+sid+" ================",a);if(b<0)b=text.length();String block=text.substring(a,b);
        // v15.10.251: fail closed when an appended rich-trace/backfill block disagrees with
        // the first retained Ultimate-AI result for this historical session.  The first block
        // is the publication/S authority; a later diagnostic reconstruction must never silently
        // replace its bankroll/termination path merely because it contains AI_TRACE rows.
        if(a!=first && !"20260920_003052".equals(sid)){
            int fb=text.indexOf("================ END ULTIMATE AI | SESSION "+sid+" ================",first);
            if(fb<0)fb=text.length();
            String authoritative=text.substring(first,fb);
            Pattern summary=Pattern.compile("Ultimate AI: hands\\s+(\\d+)\\s+\\|\\s+bankroll\\s+£?(-?\\d+(?:\\.\\d+)?)",Pattern.CASE_INSENSITIVE);
            Matcher am=summary.matcher(authoritative), cm=summary.matcher(block);
            if(!am.find() || !cm.find())return out;
            int ah=Integer.parseInt(am.group(1)), ch=Integer.parseInt(cm.group(1));
            double ab=Double.parseDouble(am.group(2)), cb=Double.parseDouble(cm.group(2));
            if(ah!=ch || Math.abs(ab-cb)>0.001)return out;
        }
        for(String line:block.split("\\R")){if(!line.startsWith("AI_TRACE | H"))continue;String[] z=line.split("\\|",-1);CompareHand q=new CompareHand();
            for(String raw:z){String t=raw.trim();if(t.startsWith("AI_TRACE ")){Matcher m=Pattern.compile("H(\\d+)").matcher(t);if(m.find())q.number=Integer.parseInt(m.group(1));}else if(t.matches("H\\d+")){q.number=Integer.parseInt(t.substring(1));}else if(t.startsWith("before "))q.before=Double.parseDouble(t.substring(7).trim());else if(t.startsWith("after "))q.after=Double.parseDouble(t.substring(6).trim());else if(t.startsWith("wager "))q.wager=Double.parseDouble(t.substring(6).trim());else if(t.startsWith("committed "))q.committed=Double.parseDouble(t.substring(10).trim());else if(t.startsWith("result "))q.result=t.substring(7).trim();else if(t.startsWith("dealerUp "))q.dealerUp=t.substring(9).trim();else if(t.startsWith("player ")){String v=t.substring(7).trim();if(!v.isEmpty())q.player.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("dealer ")){String v=t.substring(7).trim();if(!v.isEmpty())q.dealer.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("actions ")){String v=t.substring(8).trim();if(!v.isEmpty())q.actions.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("branches ")){String v=t.substring(9).trim();if(!v.isEmpty())for(String br:v.split(";",-1)){List<String> bb=new ArrayList<>();if(!br.isEmpty())bb.addAll(Arrays.asList(br.split(",")));q.branches.add(bb);}}}
            if(q.number>0)out.add(q);
        }return out;
    }

    private void openStatWatchingComparison(){
        if(currentSession==null)return;
        try{
            StatWatchView v=parseStatWatchBlock(loadedOutputText,currentSession.id,"S"+currentSession.publicationNumber);
            List<CompareHand> sw=buildStatWatchingTrace(controllerTraceEvidenceText(currentSession.id),currentSession.id);
            if(sw.isEmpty()){openEvidencePathComparison("Stat-Watching Casual");return;}
            // v15.10.277: use the mature split-screen CompareWindow. Rich retained SW_TRACE
            // is shown through the behavioural exit; the retained stay bankroll path then keeps
            // the right pane alive without fabricating post-exit cards/actions/wagers.
            new CompareWindow(currentSession,null,sw,Math.max(0,handIndex),true,"Stat-Watching Casual",v).setVisible(true);
        }catch(Exception ex){JOptionPane.showMessageDialog(this,"Stat-Watching Casual replay could not be opened without inventing evidence:\n"+ex.getMessage(),"Replay unavailable",JOptionPane.WARNING_MESSAGE);}
    }

    private class StatWatchingReplayWindow extends JFrame{
        final Session session; final StatWatchView view; final List<Double> frozen;
        final JLabel hand=new JLabel(),frozenValue=new JLabel(),chosenValue=new JLabel(),stayValue=new JLabel(),status=new JLabel(),detail=new JLabel(); int i=0;
        StatWatchingReplayWindow(Session s,StatWatchView v,List<Double> f){
            super("Stat-Watching Casual Replay — "+sessionDisplayTitle(s)); session=s;view=v;frozen=f==null?new ArrayList<>():f;
            setDefaultCloseOperation(DISPOSE_ON_CLOSE);setSize(1180,500);setLocationRelativeTo(BlackjackSessionReplay.this);
            JPanel root=new JPanel(new BorderLayout(10,10));root.setBorder(new EmptyBorder(18,18,18,18));
            JLabel title=new JLabel("STAT-WATCHING CASUAL — BEHAVIOURAL EXIT / STAY REPLAY",SwingConstants.CENTER);title.setFont(new Font("SansSerif",Font.BOLD,20));root.add(title,BorderLayout.NORTH);
            JPanel mid=new JPanel(new GridLayout(1,3,14,0));mid.add(statPathCard("FROZEN — REFERENCE",frozenValue));mid.add(statPathCard("CHOSEN BEHAVIOURAL PATH",chosenValue));mid.add(statPathCard("STAY-AT-TABLE COUNTERFACTUAL",stayValue));root.add(mid,BorderLayout.CENTER);
            JPanel south=new JPanel();south.setLayout(new BoxLayout(south,BoxLayout.Y_AXIS));for(JLabel q:new JLabel[]{hand,status,detail})q.setAlignmentX(Component.CENTER_ALIGNMENT);south.add(hand);south.add(status);south.add(detail);
            JPanel buttons=new JPanel();JButton prev=new JButton("◀ PREVIOUS"),next=new JButton("NEXT ▶");prev.addActionListener(e->{if(i>0)i--;showPoint();});next.addActionListener(e->{int max=Math.max(frozen.size(),Math.max(view.chosenPath.size(),view.stayPath.size()))-1;if(i<max)i++;showPoint();});buttons.add(prev);buttons.add(next);south.add(buttons);root.add(south,BorderLayout.SOUTH);setContentPane(root);showPoint();
        }
        JPanel statPathCard(String name,JLabel value){JPanel p=new JPanel(new BorderLayout());p.setBorder(new CompoundBorder(new LineBorder(new Color(185,195,205)),new EmptyBorder(18,18,18,18)));JLabel n=new JLabel(name,SwingConstants.CENTER);n.setFont(new Font("SansSerif",Font.BOLD,15));value.setHorizontalAlignment(SwingConstants.CENTER);value.setFont(new Font("SansSerif",Font.BOLD,38));p.add(n,BorderLayout.NORTH);p.add(value,BorderLayout.CENTER);return p;}
        void showPoint(){
            frozenValue.setText(i<frozen.size()?"£"+money(frozen.get(i)):"—");chosenValue.setText(i<view.chosenPath.size()?"£"+money(view.chosenPath.get(i)):"—");stayValue.setText(i<view.stayPath.size()?"£"+money(view.stayPath.get(i)):"—");hand.setText("HAND "+i+"  •  retained bankroll evidence");
            if(view.exitHand>0 && i==view.exitHand){status.setText("BEHAVIOURAL EXIT HERE — chosen player walks away at £"+money(view.exitBank));status.setForeground(new Color(155,70,20));}
            else if(view.exitHand>0 && i>view.exitHand){status.setText("POST-EXIT COUNTERFACTUAL — chosen journey has ended; only the retained stay-at-table path continues");status.setForeground(new Color(90,90,90));}
            else {status.setText("PRE-EXIT — chosen and stay paths are the same Stat-Watching personality");status.setForeground(new Color(35,95,55));}
            String complete=view.staySourceComplete?"SOURCE-COMPLETE":"SOURCE-BOUNDED";detail.setText("<html><center>Stay replay: "+complete+" • same Stat-Watching wager/play personality • only the walk-away trigger is disabled.<br>No post-exit cards or actions are inferred where they are not retained; the continuation shown here is bankroll-path evidence.</center></html>");
        }
    }
    static List<CompareHand> buildStatWatchingTrace(String text,String sid)throws IOException{
        List<CompareHand> out=new ArrayList<>();if(text==null)return out;
        String start="================ STAT-WATCHING CASUAL | SESSION "+sid+" ================";int a=text.lastIndexOf(start);if(a<0)return out;int b=text.indexOf("================ END STAT-WATCHING CASUAL | SESSION "+sid+" ================",a);if(b<0)b=text.length();String block=text.substring(a,b);
        for(String line:block.split("\\R")){if(!line.startsWith("SW_TRACE | H"))continue;String[] z=line.split("\\|",-1);CompareHand q=new CompareHand();
            for(String raw:z){String t=raw.trim();if(t.startsWith("SW_TRACE ")){Matcher m=Pattern.compile("H(\\d+)").matcher(t);if(m.find())q.number=Integer.parseInt(m.group(1));}else if(t.matches("H\\d+")){q.number=Integer.parseInt(t.substring(1));}else if(t.startsWith("before "))q.before=Double.parseDouble(t.substring(7).trim());else if(t.startsWith("after "))q.after=Double.parseDouble(t.substring(6).trim());else if(t.startsWith("wager "))q.wager=Double.parseDouble(t.substring(6).trim());else if(t.startsWith("committed "))q.committed=Double.parseDouble(t.substring(10).trim());else if(t.startsWith("result "))q.result=t.substring(7).trim();else if(t.startsWith("dealerUp "))q.dealerUp=t.substring(9).trim();else if(t.startsWith("player ")){String v=t.substring(7).trim();if(!v.isEmpty())q.player.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("dealer ")){String v=t.substring(7).trim();if(!v.isEmpty())q.dealer.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("actions ")){String v=t.substring(8).trim();if(!v.isEmpty())q.actions.addAll(Arrays.asList(v.split(",")));}else if(t.startsWith("branches ")){String v=t.substring(9).trim();if(!v.isEmpty())for(String br:v.split(";",-1)){List<String> bb=new ArrayList<>();if(!br.isEmpty())bb.addAll(Arrays.asList(br.split(",")));q.branches.add(bb);}}}
            if(q.number>0)out.add(q);
        }return out;
    }

    private void openHybridComparison(){
        if(currentSession==null || !isHybridSession(currentSession) || !hasHybridFormalStartEvidence(currentSession.id))return;
        try{
            List<CompareHand> frozen=buildFrozenTrace(comparisonEvidenceText(currentSession.id),currentSession.id);
            if(frozen.isEmpty())throw new IOException("No source-supported Frozen baseline hands reconstructed");
            validateFrozenTraceAgainstHybridEvidence(comparisonEvidenceText(currentSession.id),currentSession.id,frozen);
            new HybridCompareWindow(currentSession,frozen,Math.max(0,handIndex)).setVisible(true);
        }catch(Exception ex){JOptionPane.showMessageDialog(this,"Frozen vs Hybrid comparison could not be opened without inventing evidence:\n"+ex.getMessage(),"Comparison unavailable",JOptionPane.WARNING_MESSAGE);}
    }

    static class CompareHand{
        int number; double before,after,wager,committed; String result="?",dealerUp="?";
        List<String> player=new ArrayList<>(),dealer=new ArrayList<>(),actions=new ArrayList<>();
        List<Integer> playerSeq=new ArrayList<>(),dealerSeq=new ArrayList<>();
        List<List<String>> branches=new ArrayList<>(); List<List<Integer>> branchSeq=new ArrayList<>();
    }
    static class CCard{String rank,suit,role="";int value,seq,observedHand;CCard(String r,String s,int v){rank=r;suit=s;value=v;}public String toString(){return rank+suit;}}
    enum CA{HIT,STAND,DOUBLE,SPLIT}
    static class CObserved{List<List<CCard>> segs=new ArrayList<>();List<Integer> starts=new ArrayList<>();Map<Integer,List<CCard>> byHand=new HashMap<>();}
    static class CShoe{CObserved o;int seg=-1,pos=0;CShoe(CObserved x){o=x;}void before(int h)throws EOFException{int w=-1;for(int i=0;i<o.starts.size();i++)if(o.starts.get(i)<=h)w=i;if(w<0)throw new EOFException("no observed segment");if(w!=seg){seg=w;pos=0;}/* Historical hand-boundary firewall: cards physically recorded inside an earlier observed hand remain consumed provenance even when a counterfactual controller terminated gameplay earlier. Never recycle those cards into a later hand; never rewind a controller that legitimately consumed farther. */List<CCard> cards=o.segs.get(seg);while(pos<cards.size()&&cards.get(pos).observedHand>0&&cards.get(pos).observedHand<h)pos++;}CCard draw()throws EOFException{if(seg<0||pos>=o.segs.get(seg).size())throw new EOFException("observed source exhausted");return o.segs.get(seg).get(pos++);}}
    static int ct(List<CCard> c){int t=0,a=0;for(CCard q:c){t+=q.value;if(q.value==11)a++;}while(t>21&&a-->0)t-=10;return t;}
    static boolean cs(List<CCard> c){int t=0,a=0;for(CCard q:c){t+=q.value;if(q.value==11)a++;}while(t>21&&a>0){t-=10;a--;}return a>0;}
    static boolean cb(int x,int a,int b){return x>=a&&x<=b;}
    static CA casualAct(List<CCard>c,int up,boolean first,boolean pair,boolean canExtra){int t=ct(c);if(t>=21)return CA.STAND;if(first&&pair&&canExtra&&(c.get(0).value==11||c.get(0).value==8))return CA.SPLIT;if(!cs(c)&&first&&canExtra&&(t==10||t==11)&&cb(up,2,9))return CA.DOUBLE;if(cs(c)){if(t>=19)return CA.STAND;if(t==18&&(up==2||up==7||up==8))return CA.STAND;return CA.HIT;}if(t>=17)return CA.STAND;if(t>=13)return cb(up,2,6)?CA.STAND:CA.HIT;if(t==12)return cb(up,4,6)?CA.STAND:CA.HIT;return CA.HIT;}
    static CA frozenAct(List<CCard>c,int up,boolean first,boolean pair,boolean canExtra){
        int t=ct(c); if(t>=21)return CA.STAND;
        if(pair){int q=c.get(0).value;if(q==10)return CA.STAND;if(canExtra){if(q==11||q==8)return CA.SPLIT;if(q==9&&(cb(up,2,6)||up==8||up==9))return CA.SPLIT;if(q==7&&cb(up,2,7))return CA.SPLIT;if(q==6&&cb(up,2,6))return CA.SPLIT;if(q==4&&(up==5||up==6))return CA.SPLIT;if((q==3||q==2)&&cb(up,2,7))return CA.SPLIT;}}
        if(cs(c)){if(t>=19)return CA.STAND;if(t==18){if(first&&canExtra&&cb(up,3,6))return CA.DOUBLE;if(up==2||up==7||up==8)return CA.STAND;return CA.HIT;}if(t==17){if(first&&canExtra&&cb(up,3,6))return CA.DOUBLE;return CA.HIT;}if(t==15||t==16){if(first&&canExtra&&cb(up,4,6))return CA.DOUBLE;return CA.HIT;}if(t==13||t==14){if(first&&canExtra&&(up==5||up==6))return CA.DOUBLE;return CA.HIT;}return CA.HIT;}
        if(t>=17)return CA.STAND;if(t>=13)return cb(up,2,6)?CA.STAND:CA.HIT;if(t==12)return cb(up,4,6)?CA.STAND:CA.HIT;if(t==11)return first&&canExtra&&cb(up,2,10)?CA.DOUBLE:CA.HIT;if(t==10)return first&&canExtra&&cb(up,2,9)?CA.DOUBLE:CA.HIT;if(t==9)return first&&canExtra&&cb(up,3,6)?CA.DOUBLE:CA.HIT;return CA.HIT;
    }
    enum CFState{NORMAL,COOLING,CAUTIOUS,BRAKE}
    static class FrozenTraceState{double bank=100;boolean reached130=false;CFState state=CFState.NORMAL;}
    static void updateFrozenTraceState(FrozenTraceState r){if(r.state==CFState.BRAKE)return;if(r.state==CFState.COOLING){if(r.bank>=120)r.state=CFState.CAUTIOUS;return;}if(r.state==CFState.CAUTIOUS){if(r.bank>=140)r.state=CFState.NORMAL;return;}if(r.reached130&&r.bank<=100)r.state=CFState.COOLING;}
    static double frozenTraceWager(FrozenTraceState r,int h){updateFrozenTraceState(r);if(r.state!=CFState.NORMAL)return 15;if(h<=8||r.bank<130)return 15;if(r.bank<150)return 20;if(r.bank<170)return 30;return 15;}
    static class CPlayed{List<CCard>cards;double stake;CPlayed(List<CCard>c,double s){cards=c;stake=s;}}
    static void frozenBranch(CShoe sh,List<CPlayed>out,List<CCard>cards,int up,double stake,double bank,double[]committed,List<String>acts,boolean allowSplit)throws EOFException{
        boolean first=true;while(true){if(ct(cards)>21){out.add(new CPlayed(cards,stake));return;}boolean pair=allowSplit&&first&&cards.size()==2&&cards.get(0).value==cards.get(1).value;boolean can=bank-committed[0]+.001>=stake;CA ac=frozenAct(cards,up,first,pair,can);acts.add(ac.toString());if(ac==CA.STAND){out.add(new CPlayed(cards,stake));return;}if(ac==CA.DOUBLE&&can){committed[0]+=stake;cards.add(sh.draw());out.add(new CPlayed(cards,stake*2));return;}if(ac==CA.SPLIT&&pair&&can){committed[0]+=stake;CCard x0=cards.get(0),y0=cards.get(1);List<CCard>x=new ArrayList<>(List.of(x0,sh.draw())),y=new ArrayList<>(List.of(y0,sh.draw()));if(x0.value==11&&y0.value==11){out.add(new CPlayed(x,stake));out.add(new CPlayed(y,stake));return;}frozenBranch(sh,out,x,up,stake,bank,committed,acts,false);frozenBranch(sh,out,y,up,stake,bank,committed,acts,false);return;}cards.add(sh.draw());first=false;allowSplit=false;}
    }
    static CCard cc(String raw){String x=raw.trim().toUpperCase(Locale.ROOT).replace(" ",""),su="";if(x.endsWith("♠")||x.endsWith("S")){su="♠";x=x.substring(0,x.length()-1);}else if(x.endsWith("♥")||x.endsWith("H")){su="♥";x=x.substring(0,x.length()-1);}else if(x.endsWith("♦")||x.endsWith("D")){su="♦";x=x.substring(0,x.length()-1);}else if(x.endsWith("♣")||x.endsWith("C")){su="♣";x=x.substring(0,x.length()-1);}else if(x.endsWith("U")||x.endsWith("?")){su="?";x=x.substring(0,x.length()-1);}int v;if(x.equals("A"))v=11;else if(x.matches("10|J|Q|K"))v=10;else try{v=Integer.parseInt(x);}catch(Exception e){return null;}return new CCard(x,su,v);}
    static Map<String,String> explicitUnknownSuitResolutions(String block){
        Map<String,String> out=new LinkedHashMap<>();
        Pattern handPat=Pattern.compile("(?:FINAL HAND NOTE \\| H|MINDSET_EVENT \\| H)(\\d+)",Pattern.CASE_INSENSITIVE);
        Pattern rankPat=Pattern.compile("([2-9]|10|[AJQK])\\s*(?:U|\\?)\\s*(?:IS|WAS|=|->|TO|:)\\s*(?:([2-9]|10|[AJQK])\\s*)?([SHDC♠♥♦♣])",Pattern.CASE_INSENSITIVE);
        Map<String,LinkedHashSet<String>> candidates=new LinkedHashMap<>();
        for(String line:block.split("\\R")){Matcher hm=handPat.matcher(line);if(!hm.find())continue;String h=hm.group(1);Matcher rm=rankPat.matcher(line);while(rm.find()){
            String left=rm.group(1).toUpperCase(Locale.ROOT), right=rm.group(2)==null?left:rm.group(2).toUpperCase(Locale.ROOT);if(!left.equals(right))continue;
            String su=rm.group(3).toUpperCase(Locale.ROOT);if(su.equals("♠"))su="S";else if(su.equals("♥"))su="H";else if(su.equals("♦"))su="D";else if(su.equals("♣"))su="C";
            candidates.computeIfAbsent(h+"|"+left,k->new LinkedHashSet<>()).add(left+su);
        }}
        for(Map.Entry<String,LinkedHashSet<String>> e:candidates.entrySet())if(e.getValue().size()==1)out.put(e.getKey(),e.getValue().iterator().next());
        return out;
    }

    static CObserved observed(String text,String sid)throws IOException{int a=text.lastIndexOf("================ SESSION "+sid+" ================");String block;if(a<0){
        // Dedicated corrected live reports intentionally have no cumulative SESSION wrapper.
        if(text!=null && (text.contains("blackjack_live_session_"+sid)
                || text.contains("CHRONOLOGICAL CARD DATASET (RECONSTRUCTED DEAL ORDER)")
                || text.contains("CHRONOLOGICAL CARD DATASET (CORRECTED RECONSTRUCTED DEAL ORDER)")
                || text.contains("CHRONOLOGICAL CARD DATASET (PREAMBLE)"))) block=text;
        else throw new IOException("session block not found");
    }else{int b=text.indexOf("================ END SESSION "+sid,a);if(b<0)b=text.length();block=text.substring(a,b);}
    Map<String,String> explicitU=explicitUnknownSuitResolutions(block);TreeSet<Integer> bounds=new TreeSet<>();bounds.add(1);Matcher sm=Pattern.compile("Shuffle observed before hand (\\d+)").matcher(block);while(sm.find())bounds.add(Integer.parseInt(sm.group(1)));CObserved o=new CObserved();for(int h:bounds){o.starts.add(h);o.segs.add(new ArrayList<>());}boolean in=false;for(String line:block.split("\\R")){if(line.trim().equals("CHRONOLOGICAL CARD DATASET (RECONSTRUCTED DEAL ORDER)")||line.trim().equals("CHRONOLOGICAL CARD DATASET (CORRECTED RECONSTRUCTED DEAL ORDER)")||line.trim().equals("CHRONOLOGICAL CARD DATASET (PREAMBLE)")){in=true;continue;}if(in&&line.trim().equals("AUDIT LOG"))break;if(!in)continue;Matcher m=Pattern.compile("#(\\d+) \\| H(\\d+) \\|\\s*(.*?)\\s*\\| (\\S+)\\s*$").matcher(line.trim());if(m.find()){int seq=Integer.parseInt(m.group(1)),h=Integer.parseInt(m.group(2));
            // Session 6 evidence boundary: H26 settles as a win while the captured dealer cards total 15;
            // therefore a dealer draw is missing.  Stop the reconstructable stream at #136 (end H25).
            // This is a conservative truncation: no later card is shifted backward or treated as the missing draw.
            if(sid.contains("20260907_003015") && seq>=137) continue;
            String rawCard=m.group(4);
            if(rawCard.matches("(?i)(A|[2-9]|10|J|Q|K)[U?]")){String rank=rawCard.substring(0,rawCard.length()-1).toUpperCase(Locale.ROOT);String resolved=explicitU.get(h+"|"+rank);if(resolved!=null)rawCard=resolved;}
            // Session 10 append-only evidence correction: chronology #131 was captured as 6?/6U
            // and contemporaneously resolved to 6C. Use that exact verified correction only.
            if(sid.equals("20260912_175237") && seq==131 && rawCard.matches("(?i)6[?U]")) rawCard="6C";
            CCard c=cc(rawCard);if(c==null)continue;c.seq=seq;c.observedHand=h;c.role=m.group(3).trim();int z=0;for(int i=0;i<o.starts.size();i++)if(o.starts.get(i)<=h)z=i;boolean provenanceOnly=c.role.toUpperCase(Locale.ROOT).contains("SOURCE-ONLY") || (sid.equals("20260920_003052")&&seq==58&&h==10&&c.role.toLowerCase(Locale.ROOT).contains("dealer draw"));if(!provenanceOnly)o.segs.get(z).add(c);o.byHand.computeIfAbsent(h,k->new ArrayList<>()).add(c);}}if(o.segs.stream().mapToInt(List::size).sum()==0)throw new IOException("exact observed card stream not found");return o;}
    static void branch(CShoe sh,List<List<CCard>> out,List<CCard> cards,int up,double stake,double bank,double[] committed,List<String> acts,boolean allowSplit)throws EOFException{boolean first=true;while(true){if(ct(cards)>21){out.add(cards);return;}boolean pair=allowSplit&&first&&cards.size()==2&&cards.get(0).value==cards.get(1).value;boolean can=bank-committed[0]+.001>=stake;CA ac=casualAct(cards,up,first,pair,can);acts.add(ac.toString());if(ac==CA.STAND){out.add(cards);return;}if(ac==CA.DOUBLE&&can){committed[0]+=stake;cards.add(sh.draw());out.add(cards);return;}if(ac==CA.SPLIT&&pair&&can){committed[0]+=stake;CCard x0=cards.get(0),y0=cards.get(1);List<CCard>x=new ArrayList<>(List.of(x0,sh.draw())),y=new ArrayList<>(List.of(y0,sh.draw()));if(x0.value==11){out.add(x);out.add(y);return;}branch(sh,out,x,up,stake,bank,committed,acts,false);branch(sh,out,y,up,stake,bank,committed,acts,false);return;}cards.add(sh.draw());first=false;allowSplit=false;}}
    static List<String> cstr(List<CCard>x){List<String>r=new ArrayList<>();for(CCard c:x)r.add(c.toString());return r;}
    static List<Integer> cseq(List<CCard>x){List<Integer>r=new ArrayList<>();for(CCard c:x)r.add(c.seq);return r;}
    static List<CompareHand> buildCasualTrace(String text,String sid)throws Exception{CObserved o=observed(text,sid);CShoe sh=new CShoe(o);List<CompareHand> out=new ArrayList<>();double bank=100;try{for(int h=1;h<=30;h++){double base=15;if(bank+0.001<15)break;sh.before(h);CompareHand q=new CompareHand();q.number=h;q.before=bank;q.wager=base;CCard p1=sh.draw(),hole=sh.draw(),p2=sh.draw(),up=sh.draw();q.dealerUp=up.toString();List<CCard>player=new ArrayList<>(List.of(p1,p2)),dealer=new ArrayList<>(List.of(up,hole));double[]comm={base};List<List<CCard>> ph=new ArrayList<>();double net=0;boolean pn=ct(player)==21,dn=ct(dealer)==21;if(pn){ph.add(player);q.actions.add("NATURAL");net=dn?0:1.5*base;}else{branch(sh,ph,player,up.value,base,bank,comm,q.actions,true);if(dn){for(List<CCard>x:ph)net-= (ph.size()>1?base:(comm[0]>base&&q.actions.contains("DOUBLE")?base*2:base));}else{boolean live=false;for(List<CCard>x:ph)if(ct(x)<=21)live=true;if(live)while(ct(dealer)<17)dealer.add(sh.draw());int dt=ct(dealer);for(List<CCard>x:ph){int pt=ct(x);double st=(ph.size()==1&&comm[0]>base&&q.actions.contains("DOUBLE"))?base*2:base;if(pt>21)net-=st;else if(dt>21||pt>dt)net+=st;else if(pt<dt)net-=st;}}}q.committed=comm[0];q.after=bank+net;q.result=net>0?"W":net<0?"L":"P";q.dealer=cstr(dealer);q.dealerSeq=cseq(dealer);q.player=cstr(ph.isEmpty()?player:ph.get(0));q.playerSeq=cseq(ph.isEmpty()?player:ph.get(0));for(List<CCard>x:ph){q.branches.add(cstr(x));q.branchSeq.add(cseq(x));}out.add(q);bank=q.after;}}catch(EOFException e){/* Preserve only fully settled source-supported hands; incomplete hand is never added. */}return out;}


    static List<CompareHand> buildFrozenTrace(String text,String sid)throws Exception{
        CObserved o=observed(text,sid);CShoe sh=new CShoe(o);List<CompareHand>out=new ArrayList<>();FrozenTraceState st=new FrozenTraceState();
        try{for(int h=1;h<=30;h++){double base=frozenTraceWager(st,h);if(st.bank+0.001<15||st.bank+0.001<base)break;sh.before(h);CompareHand q=new CompareHand();q.number=h;q.before=st.bank;q.wager=base;CCard p1=sh.draw(),hole=sh.draw(),p2=sh.draw(),up=sh.draw();q.dealerUp=up.toString();List<CCard>player=new ArrayList<>(List.of(p1,p2)),dealer=new ArrayList<>(List.of(up,hole));double[]comm={base};List<CPlayed>ph=new ArrayList<>();double net=0;boolean pn=ct(player)==21,dn=ct(dealer)==21;
            if(pn){ph.add(new CPlayed(player,base));q.actions.add("NATURAL");net=dn?0:1.5*base;}
            else{frozenBranch(sh,ph,player,up.value,base,st.bank,comm,q.actions,true);if(dn){for(CPlayed x:ph)net-=x.stake;}else{boolean live=false;for(CPlayed x:ph)if(ct(x.cards)<=21)live=true;if(live)while(ct(dealer)<17)dealer.add(sh.draw());int dt=ct(dealer);for(CPlayed x:ph){int pt=ct(x.cards);if(pt>21)net-=x.stake;else if(dt>21||pt>dt)net+=x.stake;else if(pt<dt)net-=x.stake;}}}
            q.committed=comm[0];q.after=st.bank+net;q.result=net>0?"W":net<0?"L":"P";q.dealer=cstr(dealer);q.dealerSeq=cseq(dealer);q.player=cstr(ph.isEmpty()?player:ph.get(0).cards);q.playerSeq=cseq(ph.isEmpty()?player:ph.get(0).cards);for(CPlayed x:ph){q.branches.add(cstr(x.cards));q.branchSeq.add(cseq(x.cards));}out.add(q);st.bank=q.after;if(st.bank>=130)st.reached130=true;}
        }catch(EOFException e){/* Preserve only fully settled source-supported hands; incomplete hand is never added. */}
        return out;
    }
    // v15.10.214: Stats Corner Ultimate AI bar labels remain vertical, enlarged for clarity, with top-edge clipping protection. No evidence or calculation logic changed.
    // v15.10.213: bounded decimal evidence parsing excludes trailing sentence punctuation.
    private void validateFrozenTraceAgainstHybridEvidence(String text,String sid,List<CompareHand>trace)throws IOException{
        String start="================ HYBRID FORMAL-START COUNTERFACTUAL | SESSION "+sid+" ================";int a=text.lastIndexOf(start);if(a<0)throw new IOException("Hybrid formal-start evidence block not found");int b=text.indexOf("================ END HYBRID FORMAL-START COUNTERFACTUAL | SESSION "+sid+" ================",a);if(b<0)b=text.length();String block=text.substring(a,b);
        Matcher m=Pattern.compile("Frozen from formal start: outcome (?:N/A|£[0-9]+(?:\\.[0-9]+)?) \\| hands (\\d+) \\| W/L/P (\\d+)/(\\d+)/(\\d+) \\| exposure £([0-9]+(?:\\.[0-9]+)?).*?(?:last source-supported bankroll|outcome) £?([0-9]+(?:\\.[0-9]+)?)?",Pattern.CASE_INSENSITIVE).matcher(block);
        int expectedHands=-1,ew=-1,el=-1,ep=-1;double expectedExposure=Double.NaN,expectedBank=Double.NaN;
        // v15.10.212: append-only reconstruction corrections are authoritative derived evidence.
        // Prefer an explicit corrected formal-start line while preserving the original capture and old derived block.
        String correctionScope="";
        Matcher correctionSession=Pattern.compile("(?is)SESSION_ID\\s*:\s*"+Pattern.quote(sid)+"(.*?)(?=SESSION_ID\\s*:|$)").matcher(text);
        if(correctionSession.find()) correctionScope=correctionSession.group();
        Matcher corrected=Pattern.compile("CORRECTED FORMAL-START FROZEN: (\\d+) hands \\| W/L/P (\\d+)/(\\d+)/(\\d+) \\| exposure £([0-9]+(?:\\.[0-9]+)?).*?last source-supported bankroll £([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(correctionScope);
        if(corrected.find()){expectedHands=Integer.parseInt(corrected.group(1));ew=Integer.parseInt(corrected.group(2));el=Integer.parseInt(corrected.group(3));ep=Integer.parseInt(corrected.group(4));expectedExposure=Double.parseDouble(corrected.group(5));expectedBank=Double.parseDouble(corrected.group(6));}

        Matcher core=Pattern.compile("Frozen from formal start: outcome (N/A|£[0-9]+(?:\\.[0-9]+)?) \\| hands (\\d+) \\| W/L/P (\\d+)/(\\d+)/(\\d+) \\| exposure £([0-9]+(?:\\.[0-9]+)?).*?last source-supported bankroll £([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(block);
        if(expectedHands<0 && core.find()){expectedHands=Integer.parseInt(core.group(2));ew=Integer.parseInt(core.group(3));el=Integer.parseInt(core.group(4));ep=Integer.parseInt(core.group(5));expectedExposure=Double.parseDouble(core.group(6));expectedBank=Double.parseDouble(core.group(7));}
        else if(expectedHands<0){Matcher complete=Pattern.compile("Frozen from formal start: outcome £([0-9]+(?:\\.[0-9]+)?) \\| hands (\\d+) \\| W/L/P (\\d+)/(\\d+)/(\\d+) \\| exposure £([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(block);if(complete.find()){expectedBank=Double.parseDouble(complete.group(1));expectedHands=Integer.parseInt(complete.group(2));ew=Integer.parseInt(complete.group(3));el=Integer.parseInt(complete.group(4));ep=Integer.parseInt(complete.group(5));expectedExposure=Double.parseDouble(complete.group(6));}}
        if(expectedHands<0)throw new IOException("Frozen formal-start evidence summary could not be parsed");
        int w=0,l=0,p=0;double ex=0;for(CompareHand h:trace){if("W".equals(h.result))w++;else if("L".equals(h.result))l++;else p++;ex+=h.committed;}double bank=trace.isEmpty()?100:trace.get(trace.size()-1).after;
        if(trace.size()!=expectedHands||w!=ew||l!=el||p!=ep||Math.abs(ex-expectedExposure)>.011||Math.abs(bank-expectedBank)>.011)throw new IOException(String.format(Locale.ROOT,"Reconstructed Frozen trace failed retained-evidence gate: got hands %d, W/L/P %d/%d/%d, exposure £%.2f, bankroll £%.2f; expected %d, %d/%d/%d, £%.2f, £%.2f",trace.size(),w,l,p,ex,bank,expectedHands,ew,el,ep,expectedExposure,expectedBank));
    }

    private static class CompactCardPanel extends JComponent {
        final String raw; final boolean differing; final int sequenceNumber; final boolean holeCard;
        CompactCardPanel(String raw){this(raw,false,0,false);}
        CompactCardPanel(String raw,boolean differing){this(raw,differing,0,false);}
        CompactCardPanel(String raw,boolean differing,int sequenceNumber){this(raw,differing,sequenceNumber,false);}
        CompactCardPanel(String raw,boolean differing,int sequenceNumber,boolean holeCard){this(raw,differing,sequenceNumber,holeCard,66);}
        CompactCardPanel(String raw,boolean differing,int sequenceNumber,boolean holeCard,int width){this.raw=raw==null?"?":raw.trim();this.differing=differing;this.sequenceNumber=sequenceNumber;this.holeCard=holeCard;Dimension d=new Dimension(width,92);setPreferredSize(d);setMinimumSize(d);setMaximumSize(d);}
        protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth()-2,h=getHeight()-2; Shape rr=new RoundRectangle2D.Double(1,1,w-1,h-1,10,10);
            g.setColor(differing?new Color(255,249,153):Color.WHITE); g.fill(rr);
            if(holeCard){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(3.2f));}
            else{g.setColor(differing?new Color(225,185,0):new Color(30,35,40));g.setStroke(new BasicStroke(differing?2.5f:1.4f));}
            g.draw(rr);
            String s=raw; boolean knownRankUnknownSuit=s.matches("(?i)(A|[2-9]|10|J|Q|K)[U?]"); boolean unknown=s.contains("UNKNOWN")||s.equals("+")||(s.contains("?")&&!knownRankUnknownSuit);
            if(unknown){g.setColor(new Color(90,90,90));g.setFont(new Font("SansSerif",Font.BOLD,28));drawCenteredCompact(g,"?",w/2,h/2+8);g.dispose();return;}
            String suit=""; for(String x:new String[]{"♥","♦","♣","♠"}) if(s.contains(x)){suit=x;break;}
            String rank=s.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();
            // v15.10.139-UAT: Compact comparison cards may carry the historical
            // unresolved-suit marker as a trailing U (e.g. 4U / 10U).  The U is
            // provenance metadata, not part of the rank.  Preserve the unknown
            // suit as ? while displaying only the known rank.
            if(suit.isEmpty() && rank.matches("(?i)(A|[2-9]|10|J|Q|K)[U?]")) rank=rank.substring(0,rank.length()-1);
            if(rank.contains("/")) rank=rank.substring(0,rank.indexOf('/'))+"?";
            Color c=(suit.equals("♥")||suit.equals("♦"))?new Color(190,35,45):new Color(25,30,35); g.setColor(c);
            g.setFont(new Font("Serif",Font.BOLD,19)); g.drawString(rank,7,22);
            g.setFont(new Font("Serif",Font.PLAIN,18)); g.drawString(suit.isEmpty()?"?":suit,7,40);
            g.setFont(new Font("Serif",Font.BOLD,31)); drawCenteredCompact(g,suit.isEmpty()?"?":suit,w/2,h/2+15);
            drawBucketMarker(g,raw,sequenceNumber>0?w-25:w-7,20,16);
            if(sequenceNumber>0){int r=22,cx=w-12,cy=12;g.setColor(new Color(72,132,196));g.fillOval(cx-r/2,cy-r/2,r,r);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,sequenceNumber<100?9:8));String n=String.valueOf(sequenceNumber);FontMetrics fm=g.getFontMetrics();g.drawString(n,cx-fm.stringWidth(n)/2,cy+(fm.getAscent()-fm.getDescent())/2);}
            g.dispose();
        }
        private void drawCenteredCompact(Graphics2D g,String s,int x,int y){FontMetrics fm=g.getFontMetrics();g.drawString(s,x-fm.stringWidth(s)/2,y);}
    }

    private String comparisonEvidenceSummary(String sid){
        // v15.10.325-UAT: standalone-accounted observed preambles are not represented by the
        // legacy SESSION <id> aggregate block.  Derive their decision-point summary directly
        // from the two source-supported traces instead of incorrectly displaying N/A.
        if(currentSession!=null&&currentSession.standalonePreamble&&sid!=null&&sid.equals(currentSession.id)){
            try{
                List<CompareHand> f=buildFrozenTrace(comparisonEvidenceText(sid),sid);
                List<CompareHand> o=observedPreambleTrace(currentSession);
                int n=Math.min(f.size(),o.size()), comparable=0, matched=0, different=0;
                for(int i=0;i<n;i++){
                    String fa=firstCasualAction(f.get(i)), oa=firstCasualAction(o.get(i));
                    if(fa.isBlank()||oa.isBlank())continue;
                    comparable++; if(fa.equals(oa))matched++; else different++;
                }
                if(comparable>0){double pct=100.0*matched/comparable;return String.format(Locale.ROOT,"COMPARABLE DECISION POINTS  %d     ✓ MATCHED  %d     ✕ DIFFERED  %d     ALIGNMENT  %.1f%%",comparable,matched,different,pct);}
            }catch(Exception ignored){}
            return "COMPARABLE DECISION POINTS: N/A";
        }
        if(loadedOutputText==null||sid==null)return "COMPARABLE DECISION POINTS: N/A";
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a<0)return "COMPARABLE DECISION POINTS: N/A";
        int b=loadedOutputText.indexOf("================ SESSION ",a+20); if(b<0)b=loadedOutputText.length();
        String block=loadedOutputText.substring(a,b);
        Matcher m=Pattern.compile("Directly Comparable Action Decisions:\\s*(\\d+)\\s*\\|\\s*Matched\\s*(\\d+)\\s*\\|\\s*Different\\s*(\\d+)\\s*\\|\\s*Alignment\\s*([0-9.]+%)",Pattern.CASE_INSENSITIVE).matcher(block);
        String last=null;
        while(m.find()) last="COMPARABLE DECISION POINTS  "+m.group(1)+"     ✓ MATCHED  "+m.group(2)+"     ✕ DIFFERED  "+m.group(3)+"     ALIGNMENT  "+m.group(4);
        return last==null?"COMPARABLE DECISION POINTS: N/A":last;
    }
    private String firstFrozenAction(Hand h){return h==null||h.actions.isEmpty()?"":h.actions.get(0).trim().toUpperCase(Locale.ROOT);}
    private String firstCasualAction(CompareHand h){return h==null||h.actions.isEmpty()?"":h.actions.get(0).trim().toUpperCase(Locale.ROOT);}
    private String rankOnly(String raw){
        if(raw==null)return ""; String x=raw.trim().toUpperCase(Locale.ROOT);
        return x.replace("♠","").replace("♥","").replace("♦","").replace("♣","").replaceAll("[SHDC]$","").trim();
    }
    private boolean sameOpeningState(Hand f,CompareHand c){
        if(f==null||c==null||f.player.size()<2||c.player.size()<2)return false;
        String fd=rankOnly(f.dealerUp), cd=rankOnly(c.dealerUp); if(!fd.equals(cd))return false;
        List<String> a=new ArrayList<>(List.of(rankOnly(f.player.get(0)),rankOnly(f.player.get(1))));
        List<String> b=new ArrayList<>(List.of(rankOnly(c.player.get(0)),rankOnly(c.player.get(1))));
        Collections.sort(a); Collections.sort(b); return a.equals(b);
    }

    // v15.10.123-UAT: the cumulative SAME/DIFFERENT labels must agree with what the operator
    // sees in the juxtaposed replay. A hand is DIFFERENT only when the visible card allocation
    // would produce at least one yellow-highlighted card on either side. Action agreement remains
    // a separate metric and is not silently folded into the card-allocation decision-point label.
    private boolean sameCardMultiset(List<String> a,List<String> b){
        if(a==null||b==null||a.size()!=b.size())return false;
        List<String>x=new ArrayList<>(),y=new ArrayList<>();for(String q:a)x.add(q==null?"":q.trim().toUpperCase(Locale.ROOT).replace(" ",""));for(String q:b)y.add(q==null?"":q.trim().toUpperCase(Locale.ROOT).replace(" ",""));Collections.sort(x);Collections.sort(y);return x.equals(y);
    }
    private boolean sameVisibleAllocation(Hand h,CompareHand c){
        if(h==null||c==null)return false;
        List<String> hd=new ArrayList<>();hd.add(h.dealerUp);hd.addAll(h.dealerHidden);
        if(!sameCardMultiset(hd,c.dealer))return false;
        if(h.split||c.branches.size()>1){
            List<List<String>> hb=new ArrayList<>();if(h.split){hb.add(h.splitA);hb.add(h.splitB);}else hb.add(h.player);
            List<List<String>> cb=c.branches.size()>1?c.branches:new ArrayList<>(List.of(c.player));
            if(hb.size()!=cb.size())return false;for(int i=0;i<hb.size();i++)if(!sameCardMultiset(hb.get(i),cb.get(i)))return false;return true;
        }
        return sameCardMultiset(h.player,c.player);
    }
    private boolean sameVisibleAllocationCompare(CompareHand a,CompareHand b){
        if(a==null||b==null||!sameCardMultiset(a.dealer,b.dealer))return false;
        List<List<String>> ab=a.branches.size()>1?a.branches:new ArrayList<>(List.of(a.player));
        List<List<String>> bb=b.branches.size()>1?b.branches:new ArrayList<>(List.of(b.player));
        if(ab.size()!=bb.size())return false;for(int i=0;i<ab.size();i++)if(!sameCardMultiset(ab.get(i),bb.get(i)))return false;return true;
    }

    private int maxSeq(List<Integer> xs){int m=0;if(xs!=null)for(Integer x:xs)if(x!=null&&x>m)m=x;return m;}
    private int compareHandEndPointer(CompareHand h){
        if(h==null)return 0;int m=Math.max(maxSeq(h.playerSeq),maxSeq(h.dealerSeq));
        if(h.branchSeq!=null)for(List<Integer>x:h.branchSeq)m=Math.max(m,maxSeq(x));return m;
    }
    private int observedHandEndPointer(Session s,int handNumber){
        // Physical source position is provenance, not a blackjack-policy simulation.
        // If the captured casino/source emitted a card after the observed player had already
        // busted, do not render it as a dealer hit, but DO retain its sequence position here.
        // Otherwise the next hand falsely appears to rewind/re-use that source card.
        if(s==null||observedSourceForPointer(s)==null)return 0;List<CCard> cards=observedSourceForPointer(s).byHand.get(handNumber);
        int m=0;if(cards!=null)for(CCard c:cards)if(c!=null&&c.seq>m)m=c.seq;return m;
    }
    private CObserved observedSourceForPointer(Session s){
        try{return observed(loadedOutputText,s.id);}catch(Exception ex){return null;}
    }
    private String localDriftText(int otherPointer,int frozenPointer){
        if(otherPointer<=0||frozenPointer<=0)return "CARD POSITION VS FROZEN: N/A";
        int d=otherPointer-frozenPointer;return "CARD POSITION VS FROZEN: "+(d>0?"+":"")+d;
    }

    private JPanel comparisonStatsBox(String target,JLabel decisionStreak,JLabel wagerStreak,JLabel pl){
        JPanel box=new JPanel();box.setLayout(new BoxLayout(box,BoxLayout.Y_AXIS));box.setBackground(Color.WHITE);
        box.setBorder(new CompoundBorder(new LineBorder(new Color(88,165,45),2),new EmptyBorder(6,12,6,12)));
        decisionStreak.setFont(new Font("SansSerif",Font.PLAIN,12));wagerStreak.setFont(new Font("SansSerif",Font.PLAIN,12));pl.setFont(new Font("SansSerif",Font.PLAIN,12));
        Color green=new Color(82,157,45);decisionStreak.setForeground(green);wagerStreak.setForeground(green);pl.setForeground(green);
        decisionStreak.setAlignmentX(Component.LEFT_ALIGNMENT);wagerStreak.setAlignmentX(Component.LEFT_ALIGNMENT);pl.setAlignmentX(Component.LEFT_ALIGNMENT);
        box.add(decisionStreak);box.add(wagerStreak);box.add(Box.createVerticalStrut(3));
        JLabel rule=new JLabel("PROFIT/LOSS DURING SAME DECISION ACTIONS");rule.setFont(new Font("SansSerif",Font.PLAIN,11));rule.setForeground(green);rule.setAlignmentX(Component.LEFT_ALIGNMENT);box.add(rule);box.add(pl);
        box.setPreferredSize(new Dimension(440,92));box.setMaximumSize(new Dimension(520,92));box.setAlignmentX(Component.CENTER_ALIGNMENT);return box;
    }

    private JPanel greenPLBox(String title,String target,double frozenPL,double otherPL){
        JPanel box=new JPanel();box.setLayout(new BoxLayout(box,BoxLayout.Y_AXIS));box.setBackground(Color.WHITE);
        Color green=new Color(82,157,45);box.setBorder(new CompoundBorder(new LineBorder(green,2),new EmptyBorder(6,12,6,12)));
        JLabel head=new JLabel(title);head.setFont(new Font("SansSerif",Font.PLAIN,11));head.setForeground(green);head.setAlignmentX(Component.LEFT_ALIGNMENT);box.add(head);
        JPanel row=new JPanel(new GridLayout(1,2,28,0));row.setOpaque(false);row.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel a=new JLabel("FROZEN  "+signedMoney(frozenPL));JLabel b=new JLabel(target+"  "+signedMoney(otherPL));
        a.setFont(new Font("SansSerif",Font.PLAIN,12));b.setFont(new Font("SansSerif",Font.PLAIN,12));a.setForeground(green);b.setForeground(green);row.add(a);row.add(b);box.add(row);
        box.setPreferredSize(new Dimension(440,58));box.setMaximumSize(new Dimension(520,58));box.setAlignmentX(Component.CENTER_ALIGNMENT);return box;
    }
    private JPanel currentDecisionPointStreakBox(String target,boolean samePoint,boolean sameWager,boolean sameAction,int streakHands,double frozenPL,double otherPL,JLabel pos,JLabel driftBadge,boolean entryPointCounterfactual){
        Color green=new Color(82,157,45);
        JPanel outer=new JPanel(new GridBagLayout());outer.setOpaque(false);

        JPanel detailBox=new JPanel();detailBox.setLayout(new BoxLayout(detailBox,BoxLayout.Y_AXIS));detailBox.setBackground(Color.WHITE);
        detailBox.setBorder(new CompoundBorder(new LineBorder(green,2),new EmptyBorder(0,12,0,12)));
        String runKind=samePoint?"SAME":"DIFFERENT";
        String valueStyle="color:#006B2D;font-weight:bold;";
        if(entryPointCounterfactual){
            JLabel head=new JLabel("ENTRY-POINT COUNTERFACTUAL — JOURNEY-LEVEL RUNNING TOTALS");head.setFont(new Font("SansSerif",Font.PLAIN,9));head.setForeground(green);head.setAlignmentX(Component.LEFT_ALIGNMENT);detailBox.add(head);
            JLabel basis=new JLabel("<html><span style='color:#529D2D;'>STARTING BASIS:&nbsp;&nbsp;</span><span style='"+valueStyle+"'>DIFFERENT</span><span style='color:#529D2D;'>&nbsp;&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;&nbsp;HAND-LEVEL COMPARISON:&nbsp;</span><span style='"+valueStyle+"'>N/A</span></html>");basis.setFont(new Font("SansSerif",Font.PLAIN,10));basis.setAlignmentX(Component.LEFT_ALIGNMENT);detailBox.add(basis);
            JLabel relation=new JLabel("<html><span style='color:#529D2D;'>CARD-POSITION RELATION:&nbsp;&nbsp;</span><span style='"+valueStyle+"'>FORMAL START ↔ PREAMBLE START</span></html>");relation.setFont(new Font("SansSerif",Font.PLAIN,10));relation.setAlignmentX(Component.LEFT_ALIGNMENT);detailBox.add(relation);
        }else{
            JLabel head=new JLabel("RUNNING TOTALS DURING CURRENT "+runKind+" START-HAND COMPARISON STREAK");head.setFont(new Font("SansSerif",Font.PLAIN,9));head.setForeground(green);head.setAlignmentX(Component.LEFT_ALIGNMENT);detailBox.add(head);
            JLabel same=new JLabel("<html><span style='color:#529D2D;'>"+runKind+" START-HAND POINT:&nbsp;&nbsp;</span><span style='"+valueStyle+"'>YES</span><span style='color:#529D2D;'>&nbsp;&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;&nbsp;START-HAND STREAK:&nbsp;</span><span style='"+valueStyle+"'>"+streakHands+" HAND"+(streakHands==1?"":"S")+"</span></html>");same.setFont(new Font("SansSerif",Font.PLAIN,10));same.setAlignmentX(Component.LEFT_ALIGNMENT);detailBox.add(same);
            JLabel detail=new JLabel("<html><span style='color:#529D2D;'>SAME WAGER AMOUNT:&nbsp;&nbsp;</span><span style='"+valueStyle+"'>"+(sameWager?"YES":"NO")+"</span><span style='color:#529D2D;'>&nbsp;&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;&nbsp;SAME PLAYER ACTION:&nbsp;&nbsp;</span><span style='"+valueStyle+"'>"+(sameAction?"YES":"NO")+"</span></html>");detail.setFont(new Font("SansSerif",Font.PLAIN,10));detail.setAlignmentX(Component.LEFT_ALIGNMENT);detailBox.add(detail);
        }

        JPanel centerBox=new JPanel();centerBox.setOpaque(false);centerBox.setLayout(new BoxLayout(centerBox,BoxLayout.Y_AXIS));
        pos.setFont(new Font("SansSerif",Font.BOLD,17));pos.setAlignmentX(Component.CENTER_ALIGNMENT);pos.setHorizontalAlignment(SwingConstants.CENTER);
        driftBadge.setFont(new Font("SansSerif",Font.BOLD,14));driftBadge.setBorder(new CompoundBorder(new LineBorder(Color.BLACK,1),new EmptyBorder(0,10,0,10)));driftBadge.setAlignmentX(Component.CENTER_ALIGNMENT);driftBadge.setHorizontalAlignment(SwingConstants.CENTER);
        centerBox.add(pos);centerBox.add(driftBadge);

        JPanel profitBox=new JPanel();profitBox.setLayout(new BoxLayout(profitBox,BoxLayout.Y_AXIS));profitBox.setBackground(Color.WHITE);
        profitBox.setBorder(new CompoundBorder(new LineBorder(green,2),new EmptyBorder(0,10,0,10)));
        JLabel a=new JLabel("FROZEN RUNNING PROFIT   "+signedMoney(frozenPL),SwingConstants.CENTER);JLabel b=new JLabel(target+" RUNNING PROFIT   "+signedMoney(otherPL),SwingConstants.CENTER);
        a.setFont(new Font("SansSerif",Font.BOLD,11));b.setFont(new Font("SansSerif",Font.BOLD,11));a.setForeground(green);b.setForeground(green);a.setAlignmentX(Component.CENTER_ALIGNMENT);b.setAlignmentX(Component.CENTER_ALIGNMENT);
        profitBox.add(a);profitBox.add(b);

        GridBagConstraints gc=new GridBagConstraints();gc.gridy=0;gc.fill=GridBagConstraints.BOTH;gc.weighty=1.0;
        gc.gridx=0;gc.weightx=.42;outer.add(detailBox,gc);
        gc.gridx=1;gc.weightx=.20;gc.insets=new Insets(0,8,0,8);outer.add(centerBox,gc);
        gc.gridx=2;gc.weightx=.38;gc.insets=new Insets(0,0,0,0);outer.add(profitBox,gc);
        outer.setPreferredSize(new Dimension(100,42));outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,42));outer.setAlignmentX(Component.CENTER_ALIGNMENT);return outer;
    }

    static class ReplayStoryMetrics{
        double peak=100,trough=100,exposure=0,maxDrawdown=0; int w=0,l=0,p=0,hands=0;
        double avg(){return hands==0?0:exposure/hands;}
    }
    int outcomeBucket(String raw){String x=friendlyOutcome(raw).toUpperCase(Locale.ROOT);if(x.contains("WIN"))return 1;if(x.contains("LOSS"))return -1;if(x.contains("PUSH"))return 0;return 9;}
    ReplayStoryMetrics observedMetrics(Session s,int through){
        ReplayStoryMetrics m=new ReplayStoryMetrics(); if(s==null||s.hands.isEmpty()||through<0)return m;
        double start=s.hands.get(0).researchStart(),runningPeak=start;m.peak=start;m.trough=start;
        int n=Math.min(through+1,s.hands.size());
        for(int i=0;i<n;i++){Hand h=s.hands.get(i);double end=h.researchEnd();m.peak=Math.max(m.peak,end);m.trough=Math.min(m.trough,end);runningPeak=Math.max(runningPeak,end);m.maxDrawdown=Math.max(m.maxDrawdown,runningPeak-end);m.exposure+=parseMoney(h.committed);int b=outcomeBucket(h.result);if(b==1)m.w++;else if(b==-1)m.l++;else if(b==0)m.p++;m.hands++;}
        return m;
    }
    ReplayStoryMetrics compareMetrics(List<CompareHand> hs,int through){
        ReplayStoryMetrics m=new ReplayStoryMetrics(); if(hs==null||hs.isEmpty()||through<0)return m;
        double start=hs.get(0).before,runningPeak=start;m.peak=start;m.trough=start;
        int n=Math.min(through+1,hs.size());
        for(int i=0;i<n;i++){CompareHand h=hs.get(i);double end=h.after;m.peak=Math.max(m.peak,end);m.trough=Math.min(m.trough,end);runningPeak=Math.max(runningPeak,end);m.maxDrawdown=Math.max(m.maxDrawdown,runningPeak-end);m.exposure+=h.committed;int b=outcomeBucket(h.result);if(b==1)m.w++;else if(b==-1)m.l++;else if(b==0)m.p++;m.hands++;}
        return m;
    }
    String storyMetricsLine(ReplayStoryMetrics m){return "PEAK £"+money(m.peak)+"   •   TROUGH £"+money(m.trough)+"   •   EXPOSURE £"+money(m.exposure)+"   •   MAX DRAWDOWN £"+money(m.maxDrawdown);}
    String storyResultsLine(ReplayStoryMetrics m){return "RESULTS W/L/P  "+m.w+" / "+m.l+" / "+m.p+"   •   AVG BET / HAND £"+money(m.avg());}

    private JPanel storyMetricCell(String title,String value,Color valueColor){
        JPanel cell=new JPanel();cell.setLayout(new BoxLayout(cell,BoxLayout.Y_AXIS));cell.setBackground(new Color(248,248,248));
        cell.setBorder(new MatteBorder(0,1,0,0,new Color(205,210,215)));
        String shownTitle=title;
        if("MAX DRAWDOWN".equals(title))shownTitle="<html><div style='text-align:center'>MAX<br>DRAWDOWN</div></html>";
        else if("AVG BET / HAND".equals(title))shownTitle="<html><div style='text-align:center'>AVG BET /<br>HAND</div></html>";
        JLabel t=new JLabel(shownTitle,SwingConstants.CENTER);t.setFont(new Font("SansSerif",Font.BOLD,11));t.setForeground(new Color(20,48,90));t.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel v=new JLabel(value,SwingConstants.CENTER);v.setFont(new Font("SansSerif",Font.BOLD,17));v.setForeground(valueColor);v.setAlignmentX(Component.CENTER_ALIGNMENT);
        cell.add(Box.createVerticalGlue());cell.add(t);cell.add(Box.createVerticalStrut(3));cell.add(v);cell.add(Box.createVerticalGlue());
        return cell;
    }
    private JPanel storyWlpCell(ReplayStoryMetrics m){
        JPanel cell=new JPanel();cell.setLayout(new BoxLayout(cell,BoxLayout.Y_AXIS));cell.setBackground(new Color(248,248,248));
        cell.setBorder(new MatteBorder(0,1,0,0,new Color(205,210,215)));
        JLabel t=new JLabel("W / L / P",SwingConstants.CENTER);t.setFont(new Font("SansSerif",Font.BOLD,11));t.setForeground(new Color(20,48,90));t.setAlignmentX(Component.CENTER_ALIGNMENT);cell.add(Box.createVerticalGlue());cell.add(t);cell.add(Box.createVerticalStrut(3));
        JPanel nums=new JPanel(new FlowLayout(FlowLayout.CENTER,4,0));nums.setOpaque(false);
        JLabel w=new JLabel(Integer.toString(m.w));w.setFont(new Font("SansSerif",Font.BOLD,17));w.setForeground(new Color(28,150,45));
        JLabel s1=new JLabel("/");s1.setFont(new Font("SansSerif",Font.BOLD,15));
        JLabel l=new JLabel(Integer.toString(m.l));l.setFont(new Font("SansSerif",Font.BOLD,17));l.setForeground(new Color(220,38,38));
        JLabel s2=new JLabel("/");s2.setFont(new Font("SansSerif",Font.BOLD,15));
        JLabel q=new JLabel(Integer.toString(m.p));q.setFont(new Font("SansSerif",Font.BOLD,17));q.setForeground(new Color(118,58,180));
        nums.add(w);nums.add(s1);nums.add(l);nums.add(s2);nums.add(q);cell.add(nums);cell.add(Box.createVerticalGlue());return cell;
    }
    private void addStoryMetricSide(JPanel strip,ReplayStoryMetrics m){
        strip.add(storyMetricCell("PEAK","£"+money(m.peak),new Color(28,150,45)));
        strip.add(storyMetricCell("TROUGH","£"+money(m.trough),new Color(220,38,38)));
        strip.add(storyMetricCell("EXPOSURE","£"+money(m.exposure),Color.BLACK));
        strip.add(storyMetricCell("MAX DRAWDOWN","£"+money(m.maxDrawdown),new Color(220,38,38)));
        strip.add(storyWlpCell(m));
        strip.add(storyMetricCell("AVG BET / HAND","£"+money(m.avg()),new Color(235,126,20)));
    }
    private JPanel storyMetricsStrip(ReplayStoryMetrics leftMetrics,ReplayStoryMetrics rightMetrics,String leftTitle,String rightTitle,Color leftHeader,Color rightHeader){
        JPanel outer=new JPanel(new BorderLayout());outer.setBackground(new Color(238,240,242));
        outer.setBorder(new CompoundBorder(new LineBorder(new Color(145,150,155),1,true),new EmptyBorder(2,5,2,5)));

        JPanel headers=new JPanel(new GridLayout(1,3,0,0));headers.setOpaque(false);
        JLabel lh=new JLabel(leftTitle,SwingConstants.CENTER);lh.setOpaque(true);lh.setBackground(leftHeader);lh.setForeground(new Color(10,41,75));lh.setFont(new Font("SansSerif",Font.BOLD,12));lh.setBorder(new MatteBorder(1,1,0,0,new Color(185,190,195)));
        JPanel headerDivide=new JPanel();headerDivide.setBackground(new Color(225,228,232));
        JLabel rh=new JLabel(rightTitle,SwingConstants.CENTER);rh.setOpaque(true);rh.setBackground(rightHeader);rh.setForeground(new Color(14,112,55));rh.setFont(new Font("SansSerif",Font.BOLD,12));rh.setBorder(new MatteBorder(1,0,0,1,new Color(185,190,195)));
        headers.add(lh);headers.add(headerDivide);headers.add(rh);
        GridLayout hg=(GridLayout)headers.getLayout();

        JPanel strip=new JPanel(new GridLayout(1,13,0,0));strip.setBackground(new Color(248,248,248));
        addStoryMetricSide(strip,leftMetrics);JPanel divide=new JPanel();divide.setBackground(new Color(225,228,232));strip.add(divide);addStoryMetricSide(strip,rightMetrics);

        JPanel content=new JPanel(new BorderLayout());content.setOpaque(false);
        JPanel headerExact=new JPanel(new GridBagLayout());headerExact.setOpaque(false);
        GridBagConstraints gc=new GridBagConstraints();gc.gridy=0;gc.fill=GridBagConstraints.BOTH;gc.weighty=1;
        gc.gridx=0;gc.weightx=6;headerExact.add(lh,gc);gc.gridx=1;gc.weightx=1;headerExact.add(headerDivide,gc);gc.gridx=2;gc.weightx=6;headerExact.add(rh,gc);
        headerExact.setPreferredSize(new Dimension(100,17));headerExact.setMaximumSize(new Dimension(Integer.MAX_VALUE,17));content.add(headerExact,BorderLayout.NORTH);content.add(strip,BorderLayout.CENTER);
        outer.add(content,BorderLayout.CENTER);
        outer.setPreferredSize(new Dimension(100,76));outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,76));return outer;
    }
    private void stylePointerBadge(JLabel x){
        x.setOpaque(true);x.setBackground(Color.BLACK);x.setForeground(Color.WHITE);x.setFont(new Font("SansSerif",Font.BOLD,14));
        x.setBorder(new CompoundBorder(new LineBorder(Color.BLACK,1),new EmptyBorder(5,10,5,10)));
    }

    // v15.10.247-UAT: comparison action display has one evidence rule for every Frozen-vs-X mode.
    // Reconstructed controller traces already carry their complete policy action trail. Observed
    // traces can predate that convention, so complete only what the retained player-card sequence
    // proves: every post-deal player card requires HIT unless the retained trail already records a
    // DOUBLE; a non-bust/non-natural settled hand then requires STAND unless DOUBLE ended the hand.
    // This never copies actions from the opposite pane and never uses SAME/DIFFERENT to alter text.
    static String reconstructedActionDisplay(List<String> actions){
        if(actions==null||actions.isEmpty())return "—";
        return String.join(" → ",actions);
    }
    static String completeObservedActionDisplay(Hand h){
        if(h==null)return "—";
        List<String>a=new ArrayList<>();for(String x:h.actions)if(x!=null&&!x.isBlank())a.add(x.trim().toUpperCase(Locale.ROOT));
        if(h.split)return a.isEmpty()?"—":String.join(" → ",a);
        boolean doubled=false;int recordedDrawActions=0;
        for(String x:a){if("DOUBLE".equals(x)){doubled=true;recordedDrawActions++;}else if("HIT".equals(x))recordedDrawActions++;}
        int provedDraws=Math.max(0,h.player.size()-2);
        int missing=Math.max(0,provedDraws-recordedDrawActions);
        int insert=a.size();if(insert>0&&"STAND".equals(a.get(insert-1)))insert--;
        for(int i=0;i<missing;i++)a.add(insert+i,"HIT");
        int total=cardTotal(h.player);boolean bust=h.playerBust||total>21;boolean natural=h.natural||isTwoCardNatural(h.player);
        if(!bust&&!natural&&!doubled&&(a.isEmpty()||!"STAND".equals(a.get(a.size()-1))))a.add("STAND");
        return a.isEmpty()?"—":String.join(" → ",a);
    }

    // v15.10.197-UAT: controller/player identity thumbnails mirror the established publication-plate symbols.
    // Presentation only: no replay evidence, cardstream, bankroll, policy or comparison logic changes.
    static class ControllerIdentityHeader extends JPanel{
        final String kind,text;
        ControllerIdentityHeader(String kind,String text){this.kind=kind;this.text=text;setOpaque(false);Dimension d=new Dimension(100,58);setPreferredSize(d);setMinimumSize(d);setMaximumSize(new Dimension(Integer.MAX_VALUE,58));}
        @Override protected void paintComponent(Graphics q){super.paintComponent(q);Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int cy=getHeight()/2,cx=48;g.setColor(new Color(13,64,174));g.setStroke(new BasicStroke(3f,BasicStroke.CAP_ROUND,BasicStroke.JOIN_ROUND));
            if("FROZEN".equals(kind)){for(int a=0;a<3;a++){double z=a*Math.PI/3;int dx=(int)(22*Math.cos(z)),dy=(int)(22*Math.sin(z));g.drawLine(cx-dx,cy-dy,cx+dx,cy+dy);for(int sign:new int[]{-1,1}){int ex=cx+sign*dx,ey=cy+sign*dy;double ang=z+(sign<0?Math.PI:0);for(double off:new double[]{-.55,.55}){int bx=(int)(9*Math.cos(ang+Math.PI+off)),by=(int)(9*Math.sin(ang+Math.PI+off));g.drawLine(ex,ey,ex+bx,ey+by);}}}}
            else if("ULTIMATE".equals(kind)){g.setStroke(new BasicStroke(3f,BasicStroke.CAP_ROUND,BasicStroke.JOIN_ROUND));g.drawLine(cx,cy-24,cx,cy-18);g.fillOval(cx-3,cy-29,6,6);g.fillRoundRect(cx-20,cy-18,40,31,7,7);g.fillRoundRect(cx-27,cy-12,7,18,3,3);g.fillRoundRect(cx+20,cy-12,7,18,3,3);g.setColor(Color.WHITE);g.fillOval(cx-11,cy-8,6,6);g.fillOval(cx+5,cy-8,6,6);g.fillRoundRect(cx-10,cy+2,20,4,2,2);g.setColor(new Color(13,64,174));g.fillRoundRect(cx-15,cy+15,30,8,4,4);}
            else if("CASUAL".equals(kind)){g.fillOval(cx-9,cy-22,18,18);g.fillOval(cx-28,cy-13,15,15);g.fillOval(cx+13,cy-13,15,15);g.fillRoundRect(cx-17,cy-2,34,24,12,12);g.fillRoundRect(cx-35,cy+2,21,19,9,9);g.fillRoundRect(cx+14,cy+2,21,19,9,9);}
            else {g.fillOval(cx-10,cy-23,20,20);g.fillRoundRect(cx-18,cy-1,36,27,12,12);g.setColor(Color.WHITE);g.fillOval(cx+7,cy+6,18,18);g.setColor(new Color(13,64,174));g.setStroke(new BasicStroke(3f));g.drawOval(cx+7,cy+6,18,18);g.drawLine(cx+16,cy+10,cx+16,cy+20);g.drawLine(cx+11,cy+15,cx+21,cy+15);}
            g.setFont(new Font("SansSerif",Font.BOLD,19));g.setColor(new Color(20,25,28));FontMetrics fm=g.getFontMetrics();int tx=Math.max(86,(getWidth()-fm.stringWidth(text))/2);g.drawString(text,tx,cy+7);g.dispose();}
    }
    JPanel identityHeader(String kind,String text){return new ControllerIdentityHeader(kind,text);}

    class CompareWindow extends JFrame{
        Session frozen; List<CompareHand> formalFrozen; List<CompareHand> casual; String targetName="Fixed-Policy Casual"; CObserved observedSource; StatWatchView statStayView; UltimateAIView ultimateStayView; int idx; boolean casualComplete; boolean useFormalFrozen; boolean differentStartingBasis; JLabel pos=new JLabel(), driftBadge=new JLabel(), evidence=new JLabel(), decision=new JLabel(); JPanel left=new JPanel(),right=new JPanel();
        JLabel streakDecision=new JLabel(),streakWager=new JLabel(); JPanel statsHost=new JPanel(),metricsHost=new JPanel(),rationaleHost=new JPanel(); JButton prevButton,nextButton; boolean finalSummaryShown=false;
        CompareWindow(Session f,List<CompareHand>ff,List<CompareHand>c,int start,boolean casualComplete){this(f,ff,c,start,casualComplete,"Fixed-Policy Casual",null);}
        CompareWindow(Session f,List<CompareHand>ff,List<CompareHand>c,int start,boolean casualComplete,String targetName){this(f,ff,c,start,casualComplete,targetName,null);}
        CompareWindow(Session f,List<CompareHand>ff,List<CompareHand>c,int start,boolean casualComplete,String targetName,StatWatchView statStayView){this(f,ff,c,start,casualComplete,targetName,statStayView,null);}
        CompareWindow(Session f,List<CompareHand>ff,List<CompareHand>c,int start,boolean casualComplete,String targetName,StatWatchView statStayView,UltimateAIView ultimateStayView){
            super("Frozen vs "+targetName+" — "+sessionDisplayTitle(f)); this.targetName=targetName; this.statStayView=statStayView; this.ultimateStayView=ultimateStayView;
            setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
            addWindowListener(new WindowAdapter(){ @Override public void windowClosed(WindowEvent e){ BlackjackSessionReplay.this.setVisible(true); BlackjackSessionReplay.this.toFront(); BlackjackSessionReplay.this.requestFocus(); } });
            frozen=f; formalFrozen=ff; casual=c; this.casualComplete=casualComplete; useFormalFrozen=ff!=null&&!ff.isEmpty();
            // v15.10.307-UAT: comparison-basis integrity. A preamble-start controller journey
            // must not be presented as a hand-level SAME/DIFFERENT comparison against the
            // observed/formal-start Frozen pane. It remains a valid journey/entry-point
            // counterfactual; only direct decision equivalence is suppressed.
            boolean targetPreambleStart=(statStayView!=null&&statStayView.preambleStart)||(ultimateStayView!=null&&"PREAMBLE_START".equalsIgnoreCase(ultimateStayView.basis));
            differentStartingBasis=!useFormalFrozen && targetPreambleStart;
            try{observedSource=observed(comparisonEvidenceText(f.id),f.id);}catch(Exception ex){observedSource=null;}
            idx=Math.min(start,Math.max(useFormalFrozen?formalFrozen.size():f.hands.size(),c.size())-1);
            Rectangle usable=GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
            setSize(Math.max(1180,usable.width-20),Math.max(720,usable.height-20)); setLocationRelativeTo(BlackjackSessionReplay.this); setLayout(new BorderLayout());

            boolean legacyS14Ultimate = "20260920_003052".equals(f.id) && targetName.toUpperCase(Locale.ROOT).contains("ULTIMATE");
            String completeHeader = legacyS14Ultimate
                    ? "<html><div style='text-align:center'>CANONICAL CHRONOLOGY DIAGNOSTIC<br>FROZEN vs ULTIMATE AI  •  SOURCE #58 EXCLUDED FROM PLAY  •  SAME V1 POLICY FAMILY</div></html>"
                    : differentStartingBasis
                    ? "<html><div style='text-align:center'>ENTRY-POINT COUNTERFACTUAL<br>FROZEN FORMAL START vs "+targetName.toUpperCase(Locale.ROOT)+" PREAMBLE START  •  DIFFERENT STARTING CARD POSITION</div></html>"
                    : "<html><div style='text-align:center'>SOURCE-VERIFIED JUXTAPOSED REPLAY<br>FROZEN vs "+targetName.toUpperCase(Locale.ROOT)+"  •  SAME CAPTURED OBSERVED STREAM</div></html>";
            JLabel h=new JLabel(casualComplete ? completeHeader :
                    "<html><div style='text-align:center'>SOURCE-SUPPORTED JUXTAPOSED REPLAY<br>"+targetName.toUpperCase(Locale.ROOT)+" INCOMPLETE / SOURCE EXHAUSTED  •  FINAL OUTCOME N/A</div></html>",SwingConstants.CENTER);
            h.setOpaque(true); h.setBackground(NAVY); h.setForeground(Color.WHITE); h.setFont(new Font("SansSerif",Font.BOLD,17)); h.setBorder(new EmptyBorder(8,8,8,8));
            // v15.10.266: BoxLayout must never size the HTML label from its hand-dependent preferred width.
            // Reserve one stable full-width/two-line header band for the whole comparison journey.
            h.setAlignmentX(Component.CENTER_ALIGNMENT);
            h.setPreferredSize(new Dimension(100,76)); h.setMinimumSize(new Dimension(100,76)); h.setMaximumSize(new Dimension(Integer.MAX_VALUE,76));
            prevButton=new JButton("◀ PREVIOUS HAND"); nextButton=new JButton("NEXT HAND ▶");
            prevButton.addActionListener(e->{if(finalSummaryShown){finalSummaryShown=false;idx=Math.max(0,comparisonLastCount()-1);showIt();}else if(idx>0){idx--;showIt();}});
            nextButton.addActionListener(e->{int last=navigationLastCount();if(idx+1<last){idx++;showIt();}else if(idx+1==last){idx=last;showIt();}});
            pos.setFont(new Font("SansSerif",Font.BOLD,15));stylePointerBadge(driftBadge);
            JPanel nav=new JPanel(new GridBagLayout()); GridBagConstraints ng=new GridBagConstraints();
            ng.gridy=0;ng.weightx=.5;ng.anchor=GridBagConstraints.EAST;ng.insets=new Insets(0,0,0,120);nav.add(prevButton,ng);
            ng.gridx=1;ng.anchor=GridBagConstraints.WEST;ng.insets=new Insets(0,120,0,0);nav.add(nextButton,ng);

            evidence.setText(casualComplete ? "<html><div style='text-align:center'>"+(legacyS14Ultimate ? "CANONICAL CHRONOLOGY COUNTERFACTUAL  •  25 SOURCE-SUPPORTED HANDS  •  V1 POLICY FAMILY UNCHANGED  •  HISTORICAL £250 PRESERVED" : comparisonEvidenceSummary(f.id))+"</div></html>" :
                    "<html><div style='text-align:center'>Supported Fixed-Policy Casual trace: " + c.size() + " complete hands preserved<br>comparison stops before unsupported/incomplete hand • no final Fixed-Policy Casual bankroll inferred</div></html>");
            if(differentStartingBasis)evidence.setText("<html><div style='text-align:center'>ENTRY-POINT COUNTERFACTUAL  •  journeys begin at different captured-card positions<br>Bankroll / exposure / card-consumption paths may be studied; hand-level SAME/DIFFERENT decisions are N/A</div></html>");
            evidence.setHorizontalAlignment(SwingConstants.CENTER);
            evidence.setOpaque(true); evidence.setBackground(new Color(235,246,238)); evidence.setForeground(new Color(18,96,55));
            evidence.setFont(new Font("SansSerif",Font.BOLD,14)); evidence.setBorder(new CompoundBorder(new MatteBorder(1,0,1,0,new Color(165,205,178)),new EmptyBorder(5,8,5,8)));
            evidence.setAlignmentX(Component.CENTER_ALIGNMENT); evidence.setPreferredSize(new Dimension(100,58)); evidence.setMinimumSize(new Dimension(100,58)); evidence.setMaximumSize(new Dimension(Integer.MAX_VALUE,58));

            decision.setHorizontalAlignment(SwingConstants.CENTER); decision.setOpaque(true);
            decision.setFont(new Font("SansSerif",Font.BOLD,14)); decision.setBorder(new EmptyBorder(5,8,5,8));
            decision.setAlignmentX(Component.CENTER_ALIGNMENT); decision.setPreferredSize(new Dimension(100,36)); decision.setMinimumSize(new Dimension(100,36)); decision.setMaximumSize(new Dimension(Integer.MAX_VALUE,36));
            nav.setAlignmentX(Component.CENTER_ALIGNMENT); nav.setPreferredSize(new Dimension(100,35)); nav.setMinimumSize(new Dimension(100,35)); nav.setMaximumSize(new Dimension(Integer.MAX_VALUE,35));

            JPanel top=new JPanel(); top.setLayout(new BoxLayout(top,BoxLayout.Y_AXIS)); top.add(h); top.add(nav); top.add(evidence); top.add(decision); add(top,BorderLayout.NORTH);

            // Comparison mode intentionally has NO Same Starting State / previous-observations side panel.
            // The full width is reserved for the two actual journeys and their cards.
            JPanel body=new JPanel(new GridLayout(1,2,4,0)); body.setBackground(Color.BLACK);
            left.setLayout(new BoxLayout(left,BoxLayout.Y_AXIS)); right.setLayout(new BoxLayout(right,BoxLayout.Y_AXIS));
            // v15.10.135-UAT: comparison panes are direct panels, never independently scrollable.
            body.add(left); body.add(right);
            metricsHost.setLayout(new BorderLayout());metricsHost.setOpaque(false);
            rationaleHost.setLayout(new BorderLayout()); rationaleHost.setOpaque(false); rationaleHost.setVisible(false);
            JPanel lowerEvidence=new JPanel(); lowerEvidence.setLayout(new BoxLayout(lowerEvidence,BoxLayout.Y_AXIS)); lowerEvidence.setOpaque(false); lowerEvidence.add(rationaleHost); lowerEvidence.add(metricsHost);
            JPanel centerWrap=new JPanel(new BorderLayout());centerWrap.setBackground(new Color(238,240,242));centerWrap.add(body,BorderLayout.CENTER);centerWrap.add(lowerEvidence,BorderLayout.SOUTH);add(centerWrap,BorderLayout.CENTER);
            // v15.10.197-UAT: match the accepted Frozen-vs-Hybrid compact legend treatment.
            // This also removes the taller FlowLayout footer that could be clipped at the bottom on Windows.
            JLabel legend=new JLabel("Yellow border = differing observed allocation   •   Dark-orange outline = dealer hole card   •   Blue badge = captured stream draw order   •   CARD POSITION = local pointer drift vs Frozen",SwingConstants.CENTER);
            legend.setFont(new Font("SansSerif",Font.BOLD,10));legend.setAlignmentX(Component.CENTER_ALIGNMENT);legend.setPreferredSize(new Dimension(100,14));legend.setMaximumSize(new Dimension(Integer.MAX_VALUE,14));
            JPanel bottom=new JPanel();bottom.setLayout(new BoxLayout(bottom,BoxLayout.Y_AXIS));
            statsHost.setLayout(new BoxLayout(statsHost,BoxLayout.Y_AXIS));statsHost.setOpaque(false);bottom.add(statsHost);bottom.add(legend);add(bottom,BorderLayout.SOUTH);
            showIt();
        }
        JLabel cl(String s,int sz,boolean bold){JLabel x=new JLabel(s,SwingConstants.CENTER);x.setFont(new Font("SansSerif",bold?Font.BOLD:Font.PLAIN,sz));x.setAlignmentX(Component.CENTER_ALIGNMENT);x.setBorder(new EmptyBorder(2,6,2,6));return x;}
        JPanel actionResultBlock(String actionText,String resultText){
            JPanel box=new JPanel(); box.setOpaque(false); box.setLayout(new BoxLayout(box,BoxLayout.Y_AXIS));
            JLabel a=new JLabel(actionText,SwingConstants.CENTER); a.setFont(new Font("SansSerif",Font.BOLD,13)); a.setAlignmentX(Component.CENTER_ALIGNMENT); a.setBorder(new EmptyBorder(0,4,0,4));
            JLabel r=new JLabel(resultText,SwingConstants.CENTER); r.setFont(new Font("SansSerif",Font.BOLD,16)); r.setAlignmentX(Component.CENTER_ALIGNMENT); r.setBorder(new EmptyBorder(0,4,0,4));
            box.add(a); box.add(r);
            box.setPreferredSize(new Dimension(100,38)); box.setMaximumSize(new Dimension(Integer.MAX_VALUE,38));
            return box;
        }
        String cardKey(String raw){return raw==null?"":raw.trim().toUpperCase(Locale.ROOT).replace(" ","");}
        boolean[] differingCards(List<String> cards,List<String> other){
            boolean[] diff=new boolean[cards.size()]; boolean[] used=new boolean[other==null?0:other.size()];
            for(int i=0;i<cards.size();i++){String k=cardKey(cards.get(i));int found=-1;for(int j=0;j<used.length;j++)if(!used[j]&&k.equals(cardKey(other.get(j)))){found=j;break;}if(found>=0)used[found]=true;else diff[i]=true;}
            return diff;
        }
        JPanel cardRow(String label,List<String> cards,List<String> otherCards,List<Integer> seqs){
            JPanel outer=new JPanel(); outer.setOpaque(false); outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));
            JLabel l=cl(label,14,true); outer.add(l);
            JPanel row=new JPanel(new FlowLayout(FlowLayout.CENTER,6,1)); row.setOpaque(false);
            boolean[] diff=differingCards(cards,otherCards);
            for(int i=0;i<cards.size();i++){int q=(seqs!=null&&i<seqs.size())?seqs.get(i):0;boolean hole=label!=null&&label.startsWith("DEALER")&&i==1;row.add(new CompactCardPanel(cards.get(i),diff[i],q,hole));} outer.add(row);
            outer.setPreferredSize(new Dimension(100, 124));
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE, 124));
            outer.setMinimumSize(new Dimension(100, 124));
            return outer;
        }
        List<Integer> frozenPlayerSeq(Hand h){List<Integer>r=new ArrayList<>();if(h==null||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;List<CCard>pool=new ArrayList<>();for(CCard c:all)if(c.role.toUpperCase(Locale.ROOT).startsWith("PLAYER"))pool.add(c);boolean[]used=new boolean[pool.size()];for(String raw:h.player){int hit=-1;for(int i=0;i<pool.size();i++)if(!used[i]&&cardKey(raw).equals(cardKey(pool.get(i).toString()))){hit=i;break;}if(hit>=0){used[hit]=true;r.add(pool.get(hit).seq);}else r.add(0);}return r;}
        List<Integer> frozenDealerSeq(Hand h){List<Integer>r=new ArrayList<>();if(h==null||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;CCard up=null,hole=null;List<CCard>draws=new ArrayList<>();for(CCard c:all){String role=c.role.toUpperCase(Locale.ROOT);if(role.contains("DEALER UPCARD"))up=c;else if(role.contains("DEALER HOLE"))hole=c;else if(role.startsWith("DEALER DRAW"))draws.add(c);}if(up!=null)r.add(up.seq);else r.add(0);if(!h.dealerHidden.isEmpty()){if(hole!=null)r.add(hole.seq);else r.add(0);for(int i=1;i<h.dealerHidden.size();i++)r.add(i-1<draws.size()?draws.get(i-1).seq:0);}return r;}
        List<CCard> postBustSourceCards(Hand h){List<CCard>r=new ArrayList<>();if(h==null||!h.playerBust||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;for(CCard c:all)if(c!=null&&c.role!=null&&c.role.toUpperCase(Locale.ROOT).startsWith("DEALER DRAW"))r.add(c);return r;}
        List<CCard> correctedObservedCards(int hand,boolean dealer){
            List<CCard> r=new ArrayList<>(); if(observedSource==null)return r; List<CCard> all=observedSource.byHand.get(hand); if(all==null)return r;
            if(!dealer){for(CCard c:all)if(c.role!=null&&c.role.toUpperCase(Locale.ROOT).startsWith("PLAYER"))r.add(c);return r;}
            CCard up=null,hole=null;List<CCard>draws=new ArrayList<>();for(CCard c:all){String role=c.role==null?"":c.role.toUpperCase(Locale.ROOT);if(role.startsWith("DEALER UPCARD"))up=c;else if(role.startsWith("DEALER HOLE"))hole=c;else if(role.startsWith("DEALER DRAW"))draws.add(c);}
            if(up!=null)r.add(up);if(hole!=null)r.add(hole);r.addAll(draws);return r;
        }
        List<String> cardStrings(List<CCard>x){List<String>r=new ArrayList<>();for(CCard c:x)r.add(c.toString());return r;}
        List<Integer> cardSeqs(List<CCard>x){List<Integer>r=new ArrayList<>();for(CCard c:x)r.add(c.seq);return r;}
        int correctedObservedEndPointer(int hand){int m=0;if(observedSource==null)return m;for(Map.Entry<Integer,List<CCard>>e:observedSource.byHand.entrySet())if(e.getKey()<=hand)for(CCard c:e.getValue())m=Math.max(m,c.seq);return m;}
        String observedActionDisplay(Hand h){return completeObservedActionDisplay(h);}
        List<Integer> frozenSeqForCards(Hand h,List<String> cards){
            List<Integer> r=new ArrayList<>(); if(h==null||observedSource==null)return r;
            List<CCard> all=observedSource.byHand.get(h.number); if(all==null)return r;
            List<CCard> pool=new ArrayList<>(); for(CCard c:all)if(c.role.toUpperCase(Locale.ROOT).startsWith("PLAYER"))pool.add(c);
            boolean[] used=new boolean[pool.size()];
            for(String raw:cards){int hit=-1;for(int j=0;j<pool.size();j++)if(!used[j]&&cardKey(raw).equals(cardKey(pool.get(j).toString()))){hit=j;break;}if(hit>=0){used[hit]=true;r.add(pool.get(hit).seq);}else r.add(0);}
            return r;
        }
        JPanel splitCompareBlock(String side,List<List<String>> branches,List<List<Integer>> seqs,List<List<String>> otherBranches,Hand fh){
            JPanel box=new JPanel(new GridLayout(1,Math.max(1,branches.size()),8,0));box.setOpaque(false);
            for(int z=0;z<branches.size();z++){
                List<String>b=branches.get(z);List<String>other=z<otherBranches.size()?otherBranches.get(z):Collections.emptyList();List<Integer>sq=(seqs!=null&&z<seqs.size())?seqs.get(z):(fh==null?Collections.emptyList():frozenSeqForCards(fh,b));
                // v15.10.202: parity with accepted Frozen-vs-Hybrid extreme split rendering.
                // A genuine fifth (or later) card is kept on the same branch row by local width compaction only.
                if(b.size()>=5){
                    JPanel outer=new JPanel();outer.setOpaque(false);outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));outer.add(cl("SPLIT "+(char)('A'+z)+"  •  TOTAL "+cardTotal(b),14,true));
                    JPanel row=new JPanel(new FlowLayout(FlowLayout.CENTER,1,1));row.setOpaque(false);boolean[]diff=differingCards(b,other);
                    for(int i=0;i<b.size();i++){int q=(sq!=null&&i<sq.size())?sq.get(i):0;row.add(new CompactCardPanel(b.get(i),diff[i],q,false,60));}
                    outer.add(row);outer.setPreferredSize(new Dimension(100,124));outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,124));box.add(outer);
                }else box.add(cardRow("SPLIT "+(char)('A'+z)+"  •  TOTAL "+cardTotal(b),b,other,sq));
            }
            box.setMaximumSize(new Dimension(Integer.MAX_VALUE,130));return box;
        }
        List<List<String>> splitActionTrails(List<String> actions,List<List<String>> branches){
            List<List<String>> out=new ArrayList<>(); if(branches==null)return out;
            int ai=0; if(actions!=null&&!actions.isEmpty()&&"SPLIT".equalsIgnoreCase(actions.get(0)))ai=1;
            for(List<String> branch:branches){
                List<String> trail=new ArrayList<>(); int drawsNeeded=Math.max(0,(branch==null?0:branch.size())-2),draws=0;
                while(actions!=null&&ai<actions.size()){
                    String a=actions.get(ai++); if("SPLIT".equalsIgnoreCase(a))continue; trail.add(a);
                    if("HIT".equalsIgnoreCase(a)){draws++; if(draws>=drawsNeeded){if(ai<actions.size()&&"STAND".equalsIgnoreCase(actions.get(ai)))trail.add(actions.get(ai++));break;}}
                    else if("DOUBLE".equalsIgnoreCase(a)){draws++;break;}
                    else if("STAND".equalsIgnoreCase(a)||"NATURAL".equalsIgnoreCase(a))break;
                }
                if(trail.isEmpty())trail.add("—"); out.add(trail);
            }
            return out;
        }
        JPanel splitActionResultBlock(List<String> actions,List<List<String>> branches,String resultText){
            JPanel box=new JPanel();box.setOpaque(false);box.setLayout(new BoxLayout(box,BoxLayout.Y_AXIS));
            List<List<String>> trails=splitActionTrails(actions,branches);
            JPanel row=new JPanel(new GridLayout(1,Math.max(1,branches.size()),8,0));row.setOpaque(false);
            for(int i=0;i<branches.size();i++){String t=i<trails.size()?String.join(" → ",trails.get(i)):"—";JLabel a=new JLabel("SPLIT "+(char)('A'+i)+": "+t,SwingConstants.CENTER);a.setFont(new Font("SansSerif",Font.BOLD,12));row.add(a);}
            String mainResult=resultText, settlementLine="";
            int si=resultText.indexOf("  •  ✓ DISPLAY SETTLEMENT");
            if(si<0) si=resultText.indexOf("  •  ⚠ DISPLAY / BANKROLL");
            if(si>=0){mainResult=resultText.substring(0,si);settlementLine=resultText.substring(si+5);}
            JLabel r=new JLabel(mainResult,SwingConstants.CENTER);r.setFont(new Font("SansSerif",Font.BOLD,16));r.setAlignmentX(Component.CENTER_ALIGNMENT);
            box.add(row);box.add(r);
            if(!settlementLine.isBlank()){JLabel sc=new JLabel(settlementLine,SwingConstants.CENTER);sc.setFont(new Font("SansSerif",Font.BOLD,12));sc.setAlignmentX(Component.CENTER_ALIGNMENT);box.add(sc);}
            int bh=settlementLine.isBlank()?38:54;box.setPreferredSize(new Dimension(100,bh));box.setMaximumSize(new Dimension(Integer.MAX_VALUE,bh));return box;
        }
        int frozenCount(){return useFormalFrozen?formalFrozen.size():frozen.hands.size();}
        CompareHand frozenCompareHand(int i){return useFormalFrozen&&i>=0&&i<formalFrozen.size()?formalFrozen.get(i):null;}
        // v15.10.237: SAME/DIFFERENT is a start-hand decision-point classification.
        // Compare only the two opening player ranks plus dealer upcard; later hits, dealer draws,
        // splits and terminal settlement must not retroactively change the start-hand point.
        boolean samePointAt(int i){
            if(i<0||i>=casual.size()||i>=frozenCount())return false;
            return useFormalFrozen?sameOpeningStateCompare(formalFrozen.get(i),casual.get(i)):sameOpeningState(frozen.hands.get(i),casual.get(i));
        }
        String frozenActionAt(int i){
            if(i<0||i>=frozenCount())return "";
            return useFormalFrozen?firstCasualAction(formalFrozen.get(i)):firstFrozenAction(frozen.hands.get(i));
        }
        double frozenWagerAt(int i){return useFormalFrozen?formalFrozen.get(i).wager:parseMoney(frozen.hands.get(i).wager);}
        double frozenExposureAt(int i){return useFormalFrozen?formalFrozen.get(i).committed:parseMoney(frozen.hands.get(i).committed);}
        double frozenPLAt(int i){return useFormalFrozen?(formalFrozen.get(i).after-formalFrozen.get(i).before):(frozen.hands.get(i).researchEnd()-frozen.hands.get(i).researchStart());}
        int frozenPointerAt(int i){
            if(i<0||i>=frozenCount())return 0;
            return useFormalFrozen?compareHandEndPointer(formalFrozen.get(i)):correctedObservedEndPointer(frozen.hands.get(i).number);
        }
        ReplayStoryMetrics frozenMetricsAt(int i){return useFormalFrozen?compareMetrics(formalFrozen,i):observedMetrics(frozen,i);}

        // v15.10.314: entry-point distance is the number of retained physical cards still
        // separating this preamble-start journey from Frozen's formal-start card.  CompareHand
        // sequence IDs are not guaranteed to be populated for reconstructed preamble traces, so
        // derive the decrement from the retained cards themselves.  The displayed hand uses its
        // start position: H1 is exactly preambleCards earlier; each subsequent hand subtracts all
        // physical cards consumed by preceding reconstructed hands.  No fixed cards/hand estimate.
        int retainedPhysicalCardCount(CompareHand h){
            if(h==null)return 0;
            int playerCards=0;
            if(h.branches!=null&&h.branches.size()>1){for(List<String>b:h.branches)if(b!=null)playerCards+=b.size();}
            else if(h.player!=null)playerCards=h.player.size();
            int dealerCards=h.dealer==null?0:h.dealer.size();
            return playerCards+dealerCards;
        }
        int retainedCardsConsumedBefore(int idx){
            int n=0,limit=Math.min(Math.max(0,idx),casual.size());
            for(int k=0;k<limit;k++)n+=retainedPhysicalCardCount(casual.get(k));
            return n;
        }
        String entryPointPositionText(int idx,CompareHand ch){
            if(!differentStartingBasis||ch==null||frozen==null||frozen.preambleCards<=0)return "";
            int delta=frozen.preambleCards-retainedCardsConsumedBefore(idx);
            if(delta>0)return "POSITION: "+delta+" card"+(delta==1?"":"s")+" earlier than Frozen formal start";
            if(delta==0)return "POSITION: SAME CARD POSITION AS FROZEN FORMAL START";
            int later=-delta;return "POSITION: "+later+" card"+(later==1?"":"s")+" later than Frozen formal start";
        }
        void addEntryPointPositionLabel(JPanel p,int idx,CompareHand ch){
            String t=entryPointPositionText(idx,ch);if(t.isBlank())return;
            JLabel x=cl(t,13,true);x.setForeground(new Color(13,91,62));x.setAlignmentX(Component.CENTER_ALIGNMENT);p.add(x);
        }

        void fill(JPanel p,String title,int i,boolean cas){
            p.removeAll(); p.setBackground(cas?new Color(235,247,239):new Color(230,242,247));
            p.add(Box.createVerticalStrut(2)); String identityKind=cas?(targetName.equals("Ultimate AI")?"ULTIMATE":(targetName.equalsIgnoreCase("Observed — Standalone Preamble")?"HYBRID":"CASUAL")):"FROZEN"; p.add(identityHeader(identityKind,title));
            Hand fh=(!useFormalFrozen&&i<frozen.hands.size())?frozen.hands.get(i):null;
            CompareHand ff=(useFormalFrozen&&i<formalFrozen.size())?formalFrozen.get(i):null;
            CompareHand ch=i<casual.size()?casual.get(i):null;
            List<String> frozenDealer=new ArrayList<>(), frozenPlayer=new ArrayList<>(), casualDealer=new ArrayList<>(), casualPlayer=new ArrayList<>();
            List<Integer> frozenDealerSeq=new ArrayList<>(),frozenPlayerSeq=new ArrayList<>();
            if(ff!=null){frozenDealer.addAll(ff.dealer);frozenPlayer.addAll(ff.player);frozenDealerSeq.addAll(ff.dealerSeq);frozenPlayerSeq.addAll(ff.playerSeq);}
            else if(fh!=null){
                List<CCard> cp=correctedObservedCards(fh.number,false),cd=correctedObservedCards(fh.number,true);
                if(!cp.isEmpty()){frozenPlayer.addAll(cardStrings(cp));frozenPlayerSeq.addAll(cardSeqs(cp));}else{frozenPlayer.addAll(fh.player);frozenPlayerSeq=frozenPlayerSeq(fh);}
                if(!cd.isEmpty()){
                    // Terminal settlement gate: after a player bust or resolved two-card natural, dealer play stops.
                    int keep=Math.min(terminalDealerCardLimit(fh.playerBust,fh.natural),cd.size()); cd=new ArrayList<>(cd.subList(0,keep));
                    frozenDealer.addAll(cardStrings(cd));frozenDealerSeq.addAll(cardSeqs(cd));
                }else{frozenDealer.add(fh.dealerUp);if(fh.playerBust||fh.natural){if(!fh.dealerHidden.isEmpty())frozenDealer.add(fh.dealerHidden.get(0));}else frozenDealer.addAll(fh.dealerHidden);frozenDealerSeq=frozenDealerSeq(fh);if((fh.playerBust||fh.natural)&&frozenDealerSeq.size()>2)frozenDealerSeq=new ArrayList<>(frozenDealerSeq.subList(0,2));}
            }
            if(ch!=null){casualDealer.addAll(ch.dealer);casualPlayer.addAll(ch.player);}
            if(cas){
                if(ch==null && statStayView!=null && i>=statStayView.exitHand && statStayView.stayPath!=null && i+1<statStayView.stayPath.size()){
                    double stayBefore=statStayView.stayPath.get(i), stayAfter=statStayView.stayPath.get(i+1);
                    p.add(cl("HAND "+(i+1)+" • STAY COUNTERFACTUAL",15,true));
                    JLabel exitBank=cl("BEHAVIOURAL EXIT  •  H"+statStayView.exitHand+"  •  BANKROLL £"+money(statStayView.exitBank),20,true);
                    exitBank.setForeground(new Color(120,78,10));p.add(exitBank);
                    p.add(Box.createVerticalStrut(28));
                    JLabel bank=cl("STAY-PATH BANKROLL   £"+money(stayBefore)+" → £"+money(stayAfter),24,true);bank.setForeground(new Color(20,104,57));p.add(bank);
                    JLabel anchor=cl("ACTUAL EXIT BANKROLL REMAINS   £"+money(statStayView.exitBank),15,true);anchor.setForeground(new Color(120,78,10));p.add(anchor);
                    p.add(Box.createVerticalStrut(18));
                    p.add(cl("BANKROLL-PATH EVIDENCE ONLY",16,true));
                    p.add(cl("Post-exit cards, wager, action and settlement are not retained for this path.",13,false));
                    p.add(cl("Nothing is inferred or copied from Frozen.",13,true));
                    p.revalidate();p.repaint();return;
                }
                if(ch==null && ultimateStayView!=null && i>=ultimateStayView.hands){
                    p.add(cl("HAND "+(i+1)+" • ULTIMATE AI EXIT BOUNDARY",15,true));
                    JLabel exitBank=cl("AI EXIT  •  H"+ultimateStayView.hands+"  •  BANKROLL £"+money(ultimateStayView.aiBank),20,true);
                    exitBank.setForeground(new Color(120,78,10));p.add(exitBank);
                    p.add(Box.createVerticalStrut(28));
                    if(ultimateStayView.stayPath!=null&&!ultimateStayView.stayPath.isEmpty()&&i+1<ultimateStayView.stayPath.size()){
                        double stayBefore=ultimateStayView.stayPath.get(i),stayAfter=ultimateStayView.stayPath.get(i+1);
                        JLabel bank=cl("STAY COUNTERFACTUAL BANKROLL   £"+money(stayBefore)+" → £"+money(stayAfter),24,true);bank.setForeground(new Color(20,104,57));p.add(bank);
                        p.add(Box.createVerticalStrut(18));p.add(cl("BANKROLL-PATH EVIDENCE ONLY",16,true));
                        p.add(cl("Post-exit cards, wager, action and settlement are shown only when retained by the Ultimate evidence.",13,false));
                    }else{
                        p.add(cl("STAY COUNTERFACTUAL: N/A — NOT RETAINED",20,true));
                        p.add(Box.createVerticalStrut(18));p.add(cl("Ultimate AI genuinely exited here; no continuation is invented.",13,true));
                    }
                    p.revalidate();p.repaint();return;
                }
                if(ch==null){p.add(Box.createVerticalStrut(45));p.add(cl("JOURNEY ENDED • bankroll/table exit",19,true));p.revalidate();p.repaint();return;}
                p.add(cl("HAND "+ch.number+" / "+casual.size(),15,true));
                p.add(cl("Bankroll £"+money(ch.before)+" → £"+money(ch.after)+"     Wager £"+money(ch.wager)+"     Exposure £"+money(ch.committed),15,true));
                // Dynamic captured-card distance: retained card pointer, not a fixed cards-per-hand decrement.
                addEntryPointPositionLabel(p,i,ch);
                p.add(cardRow("DEALER  •  TOTAL "+cardTotal(ch.dealer),ch.dealer,frozenDealer,ch.dealerSeq));
                if(ch.branches.size()>1){List<List<String>> other=new ArrayList<>();if(ff!=null&&ff.branches.size()>1)other.addAll(ff.branches);else if(fh!=null&&fh.split){other.add(fh.splitA);other.add(fh.splitB);}p.add(splitCompareBlock(targetName.toUpperCase(Locale.ROOT),ch.branches,ch.branchSeq,other,null));}
                else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(ch.player),ch.player,frozenPlayer,ch.playerSeq));
                // Juxtaposed comparison: action/outcome are carried by the comparison banner, totals, bankroll and W/L/P metrics.
                // Deliberately omit the duplicate Actions/RESULT block to preserve a consistent vertical budget across all comparison modes.
            }else{
                if(ff==null&&fh==null){
                    p.add(Box.createVerticalStrut(34));
                    p.add(cl("JOURNEY ENDED  •  H"+frozenCount(),19,true));
                    double terminalBank=useFormalFrozen?(formalFrozen.isEmpty()?100.0:formalFrozen.get(formalFrozen.size()-1).after):(frozen.hands.isEmpty()?100.0:frozen.hands.get(frozen.hands.size()-1).researchEnd());
                    JLabel terminal=cl("FINAL BANKROLL   £"+money(terminalBank),27,true);terminal.setForeground(new Color(20,63,101));p.add(terminal);
                    p.add(Box.createVerticalStrut(12));p.add(cl("Terminal Frozen state retained as the journey-level benchmark; no further Frozen hands are inferred.",12,false));
                    p.revalidate();p.repaint();return;
                }
                if(ff!=null){
                    p.add(cl("HAND "+ff.number+" / "+formalFrozen.size(),15,true));
                    p.add(cl("Bankroll £"+money(ff.before)+" → £"+money(ff.after)+"     Wager £"+money(ff.wager)+"     Exposure £"+money(ff.committed),15,true));
                    p.add(cardRow("DEALER  •  TOTAL "+cardTotal(ff.dealer),ff.dealer,casualDealer,ff.dealerSeq));
                    if(ff.branches.size()>1){List<List<String>> other=(ch!=null&&ch.branches.size()>1)?ch.branches:Collections.emptyList();p.add(splitCompareBlock("FROZEN",ff.branches,ff.branchSeq,other,null));}
                    else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(ff.player),ff.player,casualPlayer,ff.playerSeq));
                    // Juxtaposed comparison intentionally omits duplicate Actions/RESULT lines.
                }else{
                    p.add(cl("HAND "+fh.number+" / "+frozen.hands.size(),15,true));
                    p.add(cl("Bankroll £"+money(fh.researchStart())+" → £"+money(fh.researchEnd())+"     Wager £"+money(parseMoney(fh.wager))+"     Exposure £"+money(parseMoney(fh.committed)),15,true));
                    p.add(cardRow("DEALER  •  TOTAL "+cardTotal(frozenDealer),frozenDealer,casualDealer,frozenDealerSeq));
                    List<CCard> postBust=postBustSourceCards(fh);
                    if(!postBust.isEmpty()){StringBuilder note=new StringBuilder("SOURCE POSITION RETAINED • post-bust source card excluded from dealer play: ");for(int k=0;k<postBust.size();k++){if(k>0)note.append(", ");note.append("#").append(postBust.get(k).seq).append(" ").append(postBust.get(k));}JLabel n=cl(note.toString(),10,true);n.setForeground(new Color(145,82,0));p.add(n);}
                    if(fh.split){List<List<String>> branches=new ArrayList<>();branches.add(fh.splitA);branches.add(fh.splitB);List<List<String>> other=(ch!=null&&ch.branches.size()>1)?ch.branches:Collections.emptyList();p.add(splitCompareBlock("FROZEN",branches,null,other,fh));}
                    else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(frozenPlayer),frozenPlayer,casualPlayer,frozenPlayerSeq));
                    // Juxtaposed comparison intentionally omits duplicate Actions/RESULT lines.
                }
            }
            p.revalidate(); p.repaint();
        }
        int comparisonLastCount(){
            // Statistical comparison is evidence-bounded to mutually supported hand positions.
            return Math.min(frozenCount(),casual.size());
        }
        int navigationLastCount(){
            // Visual navigation continues through the longer journey. For Stat-Watching only,
            // a retained stay-at-table bankroll path may extend beyond the rich SW_TRACE.
            int controllerCount=casual.size();
            if(statStayView!=null&&statStayView.stayPath!=null)controllerCount=Math.max(controllerCount,statStayView.stayPath.size()-1);
            if(ultimateStayView!=null){controllerCount=Math.max(controllerCount,ultimateStayView.hands);if(ultimateStayView.stayPath!=null)controllerCount=Math.max(controllerCount,ultimateStayView.stayPath.size()-1);}
            return Math.max(frozenCount(),controllerCount);
        }
        JPanel casualSummarySide(String title,boolean frozenSide){
            int n=comparisonLastCount(),same=0,diff=0,longSame=0,longDiff=0,curSame=0,curDiff=0;
            double samePL=0,diffPL=0,sameExp=0,diffExp=0;
            for(int i=0;i<n;i++){CompareHand c=casual.get(i);boolean sp=samePointAt(i);double pl=frozenSide?frozenPLAt(i):(c.after-c.before);double ex=frozenSide?frozenExposureAt(i):c.committed;if(sp){same++;samePL+=pl;sameExp+=ex;curSame++;curDiff=0;longSame=Math.max(longSame,curSame);}else{diff++;diffPL+=pl;diffExp+=ex;curDiff++;curSame=0;longDiff=Math.max(longDiff,curDiff);}}
            JPanel p=new JPanel();p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));p.setBackground(frozenSide?new Color(232,245,249):new Color(242,233,248));p.setBorder(new EmptyBorder(12,18,12,18));p.add(cl("<html><div style='text-align:center'>"+title+"</div></html>",17,true));p.add(Box.createVerticalStrut(10));
            String[] rows={"SAME DECISION-POINT START HANDS   "+same,"P/L ON SAME START-HAND POINTS   "+signedMoney(samePL),"EXPOSURE ON SAME START-HAND POINTS   £"+money(sameExp),"LONGEST SAME START-HAND RUN   "+longSame+" HAND"+(longSame==1?"":"S")," ","DIFFERENT DECISION-POINT START HANDS   "+diff,"P/L ON DIFFERENT START-HAND POINTS   "+signedMoney(diffPL),"EXPOSURE ON DIFFERENT START-HAND POINTS   £"+money(diffExp),"LONGEST DIFFERENT START-HAND RUN   "+longDiff+" HAND"+(longDiff==1?"":"S")};
            for(String r:rows){JLabel x=cl(r,13,!r.equals(" "));x.setAlignmentX(Component.CENTER_ALIGNMENT);p.add(x);}return p;
        }
        JPanel casualFinalComparisonSummary(){
            int n=comparisonLastCount(),same=0,diff=0,sameAction=0;
            for(int i=0;i<n;i++){CompareHand c=casual.get(i);boolean sp=samePointAt(i);if(sp)same++;else diff++;String fa=frozenActionAt(i),ca=firstCasualAction(c);if(sp&&!fa.isEmpty()&&fa.equals(ca))sameAction++;}
            int fp=n>0?frozenPointerAt(n-1):0;int cp=n>0?compareHandEndPointer(casual.get(n-1)):0;int drift=(fp>0&&cp>0)?cp-fp:0;
            JPanel p=new JPanel(new GridLayout(1,5,1,0));p.setBackground(new Color(218,223,228));p.setBorder(new EmptyBorder(3,8,3,8));String[] heads={"COMPARABLE HANDS","START-HAND POINTS","ACTION MATCH / SAME POINTS","CARDS CONSUMED","FINAL CARD POSITION"};String[] vals={String.valueOf(n),same+" SAME / "+diff+" DIFFERENT",sameAction+" / "+same,fp+" FROZEN / "+cp+" "+targetName.toUpperCase(Locale.ROOT),(fp>0&&cp>0?((drift>0?"+":"")+drift):"N/A")};for(int i=0;i<heads.length;i++){JPanel c=new JPanel();c.setBackground(Color.WHITE);c.setLayout(new BoxLayout(c,BoxLayout.Y_AXIS));JLabel a=cl(heads[i],10,true),b=cl(vals[i],14,true);a.setForeground(new Color(30,63,101));b.setForeground(new Color(15,55,82));c.add(a);c.add(b);p.add(c);}p.setPreferredSize(new Dimension(100,62));return p;
        }
        JPanel entryPointSummarySide(String title,boolean frozenSide){
            JPanel p=new JPanel();p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));p.setBackground(frozenSide?new Color(232,245,249):new Color(242,233,248));p.setBorder(new EmptyBorder(12,18,12,18));
            p.add(cl("<html><div style='text-align:center'>"+title+"</div></html>",17,true));p.add(Box.createVerticalStrut(12));
            ReplayStoryMetrics m=frozenSide?frozenMetricsAt(Math.max(0,frozenCount()-1)):compareMetrics(casual,Math.max(0,casual.size()-1));
            double startBank=frozenSide?(useFormalFrozen?(formalFrozen.isEmpty()?100:formalFrozen.get(0).before):(frozen.hands.isEmpty()?100:frozen.hands.get(0).researchStart())):(casual.isEmpty()?100:casual.get(0).before);
            double endBank=frozenSide?(useFormalFrozen?(formalFrozen.isEmpty()?startBank:formalFrozen.get(formalFrozen.size()-1).after):(frozen.hands.isEmpty()?startBank:frozen.hands.get(frozen.hands.size()-1).researchEnd())):(casual.isEmpty()?startBank:casual.get(casual.size()-1).after);
            String basis=frozenSide?(useFormalFrozen?"FORMAL START":"RECORDED START"):"PREAMBLE START";
            String source=frozenSide?("JOURNEY ENDED H"+frozenCount()):(statStayView!=null?("BEHAVIOURAL EXIT H"+statStayView.exitHand+" AT £"+money(statStayView.exitBank)):"SOURCE-SUPPORTED H"+casual.size());
            String[] rows={"STARTING BASIS   "+basis,"SUPPORTED HANDS   "+(frozenSide?frozenCount():casual.size()),"BANKROLL PATH   £"+money(startBank)+" → £"+money(endBank),"EXPOSURE   £"+money(m.exposure),"PEAK / TROUGH   £"+money(m.peak)+" / £"+money(m.trough),"MAX DRAWDOWN   £"+money(m.maxDrawdown),"RESULTS W/L/P   "+m.w+" / "+m.l+" / "+m.p,"EVIDENCE STATE   "+source};
            for(String r:rows){JLabel x=cl(r,13,true);x.setAlignmentX(Component.CENTER_ALIGNMENT);p.add(x);}return p;
        }
        JPanel entryPointFinalComparisonSummary(){
            int n=comparisonLastCount();int fp=n>0?frozenPointerAt(n-1):0;int cp=n>0?compareHandEndPointer(casual.get(n-1)):0;
            JPanel p=new JPanel(new GridLayout(1,5,1,0));p.setBackground(new Color(218,223,228));p.setBorder(new EmptyBorder(3,8,3,8));
            String[] heads={"MUTUALLY SUPPORTED POSITIONS","HAND-LEVEL SAME / DIFFERENT","ACTION MATCH","STARTING BASIS","FINAL CARD POSITION"};
            String[] vals={String.valueOf(n),"N/A","N/A","DIFFERENT","N/A"};
            for(int i=0;i<heads.length;i++){JPanel c=new JPanel();c.setBackground(Color.WHITE);c.setLayout(new BoxLayout(c,BoxLayout.Y_AXIS));JLabel a=cl(heads[i],10,true),b=cl(vals[i],14,true);a.setForeground(new Color(30,63,101));b.setForeground(new Color(15,55,82));c.add(a);c.add(b);p.add(c);}p.setPreferredSize(new Dimension(100,62));return p;
        }
        void showEntryPointFinalSummary(){
            finalSummaryShown=true;left.removeAll();right.removeAll();
            left.add(entryPointSummarySide("FROZEN — FORMAL-START JOURNEY",true));right.add(entryPointSummarySide(targetName.toUpperCase(Locale.ROOT)+" — PREAMBLE-START JOURNEY",false));
            left.revalidate();left.repaint();right.revalidate();right.repaint();rationaleHost.removeAll();rationaleHost.setVisible(false);
            metricsHost.removeAll();metricsHost.add(entryPointFinalComparisonSummary(),BorderLayout.CENTER);metricsHost.revalidate();metricsHost.repaint();statsHost.removeAll();statsHost.revalidate();statsHost.repaint();
            decision.setText("ENTRY-POINT COUNTERFACTUAL SUMMARY  •  journey evidence only  •  hand-level SAME/DIFFERENT is N/A");decision.setBackground(new Color(226,244,232));decision.setForeground(new Color(20,104,57));
            evidence.setText("SOURCE-SUPPORTED JOURNEY COMPARISON  •  "+comparisonLastCount()+" mutually supported positions  •  Frozen "+frozenCount()+" hands  •  "+targetName+" "+casual.size()+" hands  •  different starting card positions");prevButton.setEnabled(true);nextButton.setEnabled(false);
        }
        void showCasualFinalSummary(){
            if(differentStartingBasis){showEntryPointFinalSummary();return;}
            finalSummaryShown=true;left.removeAll();right.removeAll();left.add(casualSummarySide("FROZEN — START-HAND DECISION-POINT SUMMARY",true));right.add(casualSummarySide(targetName.toUpperCase(Locale.ROOT)+" — START-HAND DECISION-POINT SUMMARY",false));left.revalidate();left.repaint();right.revalidate();right.repaint();rationaleHost.removeAll();rationaleHost.setVisible(false);metricsHost.removeAll();metricsHost.add(casualFinalComparisonSummary(),BorderLayout.CENTER);metricsHost.revalidate();metricsHost.repaint();statsHost.removeAll();statsHost.revalidate();statsHost.repaint();decision.setText("END-OF-REPLAY SUMMARY  •  cumulative evidence separated by SAME vs DIFFERENT start-hand decision points");decision.setBackground(new Color(226,244,232));decision.setForeground(new Color(20,104,57));evidence.setText("SOURCE-SUPPORTED COMPARISON  •  "+comparisonLastCount()+" mutually supported hand positions  •  Frozen "+frozenCount()+" hands  •  "+targetName+" "+casual.size()+" hands  •  no evidence inferred after either journey ends");prevButton.setEnabled(true);nextButton.setEnabled(false);
        }
        void updateComparisonStats(){
            statsHost.removeAll();
            if(idx<frozenCount()&&idx<casual.size()){
                CompareHand c=casual.get(idx);String fa=frozenActionAt(idx),ca=firstCasualAction(c);
                boolean samePoint=samePointAt(idx);
                boolean sameWager=Math.abs(frozenWagerAt(idx)-c.wager)<0.005;
                boolean sameAction=!fa.isEmpty()&&!ca.isEmpty()&&fa.equals(ca);
                int streakHands=0;double runF=0,runC=0;
                for(int j=idx;j>=0;j--){
                    if(j>=frozenCount()||j>=casual.size())break;
                    CompareHand cj=casual.get(j);
                    if(samePointAt(j)!=samePoint)break;
                    streakHands++;runF+=frozenPLAt(j);runC+=cj.after-cj.before;
                }
                statsHost.add(currentDecisionPointStreakBox(targetName.toUpperCase(Locale.ROOT),samePoint,sameWager,sameAction,streakHands,runF,runC,pos,driftBadge,differentStartingBasis));
            }
            statsHost.revalidate();statsHost.repaint();
        }
        List<String> controllerRationaleForHand(int hand){
            // v15.10.249-UAT: fail closed. A controller note is shown only when the
            // retained override describes the exact displayed hand AND its claimed
            // controller value agrees with the reconstructed hand on screen.
            // This prevents a same-number record from another/legacy evidence block
            // being presented as rationale for the current comparison.
            LinkedHashSet<String> out=new LinkedHashSet<>(); if(hand<=0||idx<0||idx>=casual.size())return new ArrayList<>(out);
            CompareHand shown=casual.get(idx);
            if(shown.number!=hand)return new ArrayList<>(out);
            if("Ultimate AI".equalsIgnoreCase(targetName)){
                String block=ultimateAIEvidenceText(frozen.id);
                Matcher m=Pattern.compile("(?m)^AI_OVERRIDE \\| H"+hand+" ([A-Z]+) \\| (.+)$").matcher(block==null?"":block);
                while(m.find()){
                    String kind=m.group(1).trim().toUpperCase(Locale.ROOT), detail=m.group(2).trim();
                    if("WAGER".equals(kind)){
                        Matcher wm=Pattern.compile("Frozen £([0-9.]+)\\s*->\\s*AI £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(detail);
                        if(!wm.find())continue;
                        double claimedFrozen=parseMoney(wm.group(1)),claimedAI=parseMoney(wm.group(2));
                        double shownFrozen=frozenWagerAt(idx);
                        if(Math.abs(claimedFrozen-shownFrozen)>0.005||Math.abs(claimedAI-shown.wager)>0.005||Math.abs(shownFrozen-shown.wager)<0.005)continue;
                        out.add("WAGER: "+detail);
                    }else if("EXIT".equals(kind)){
                        Matcher bm=Pattern.compile("bankroll £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(detail);
                        if(!bm.find())continue;
                        double claimed=parseMoney(bm.group(1));
                        if(Math.abs(claimed-shown.after)>0.005)continue;
                        out.add("EXIT: "+detail);
                    }
                    // No ACTION override schema is retained here, so no action rationale is invented.
                }
            }
            if("Stat-Watching Casual".equalsIgnoreCase(targetName)){
                String block=controllerTraceEvidenceText(frozen.id);
                String start="================ STAT-WATCHING CASUAL | SESSION "+frozen.id+" ================";
                int a=block==null?-1:block.lastIndexOf(start);
                if(a>=0){
                    int b=block.indexOf("================ END STAT-WATCHING CASUAL | SESSION "+frozen.id+" ================",a);
                    if(b<0)b=block.length();
                    String swb=block.substring(a,b);
                    Matcher im=Pattern.compile("(?m)^INFLUENCE \\| H"+hand+" \\| ([A-Z]+) \\| (.+)$").matcher(swb);
                    while(im.find()){
                        String kind=im.group(1).trim().toUpperCase(Locale.ROOT),detail=im.group(2).trim();
                        if("EXIT".equals(kind)){
                            Matcher bm=Pattern.compile("walked away at £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(detail);
                            if(!bm.find())continue;
                            double claimed=parseMoney(bm.group(1));
                            if(Math.abs(claimed-shown.after)>0.005)continue;
                            out.add("OBSERVATION / EXIT: "+detail);
                        }else{
                            // Non-exit retained observations may be shown only on their exact hand.
                            out.add("OBSERVATION: "+detail);
                        }
                    }
                }
            }
            return new ArrayList<>(out);
        }
        JPanel controllerRationalePanel(int hand){
            JPanel p=new JPanel();p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));p.setBackground(new Color(250,247,235));p.setBorder(new CompoundBorder(new MatteBorder(1,0,1,0,new Color(218,205,157)),new EmptyBorder(5,12,5,12)));
            List<String> rs=controllerRationaleForHand(hand);if(rs.isEmpty())return p;
            JLabel title=cl(targetName.toUpperCase(Locale.ROOT)+" — RECORDED CONTROLLER RATIONALE",11,true);title.setForeground(new Color(90,70,15));p.add(title);
            for(String r:rs){JLabel x=cl("<html><div style='text-align:center'>"+html(r)+"</div></html>",10,false);x.setForeground(new Color(65,65,55));p.add(x);}
            return p;
        }
        void updateControllerRationale(){
            rationaleHost.removeAll();int hand=(idx<casual.size()?casual.get(idx).number:idx+1);JPanel rp=controllerRationalePanel(hand);
            if(rp.getComponentCount()>0){rationaleHost.add(rp,BorderLayout.CENTER);rationaleHost.setVisible(true);}else rationaleHost.setVisible(false);
            rationaleHost.revalidate();rationaleHost.repaint();
        }
        void showIt(){
            int last=navigationLastCount();
            if(idx>=last){showCasualFinalSummary();return;}
            finalSummaryShown=false;prevButton.setEnabled(idx>0);nextButton.setEnabled(true);
            String frozenTitle=(frozen!=null&&frozen.standalonePreamble)?"FROZEN — STANDALONE PREAMBLE REFERENCE":(useFormalFrozen?"FROZEN — FORMAL-START RECONSTRUCTED":"FROZEN — OBSERVED"); String rightTitle=(frozen!=null&&frozen.standalonePreamble&&targetName.equalsIgnoreCase("Observed — Standalone Preamble"))?"OBSERVED — STANDALONE PREAMBLE":targetName.toUpperCase(Locale.ROOT)+(targetName.equals("Ultimate AI")?" — DETERMINISTIC COUNTERFACTUAL":(statStayView!=null&&idx>=statStayView.exitHand?" — STAY COUNTERFACTUAL":" — RECONSTRUCTED")); fill(left,frozenTitle,idx,false); fill(right,rightTitle,idx,true); int fp=frozenPointerAt(idx); int cp=idx<casual.size()?compareHandEndPointer(casual.get(idx)):0; pos.setText("HAND POSITION "+(idx+1));driftBadge.setText(localDriftText(cp,fp));metricsHost.removeAll();String compareMetricsLabel=(frozen!=null&&frozen.standalonePreamble&&targetName.equalsIgnoreCase("Observed — Standalone Preamble"))?"OBSERVED — STANDALONE PREAMBLE":targetName.toUpperCase(Locale.ROOT)+(targetName.equals("Ultimate AI")?" (Deterministic Counterfactual)":(statStayView!=null&&idx>=statStayView.exitHand?" (Stay Counterfactual)":" (Reconstructed)"));
            metricsHost.add(storyMetricsStrip(frozenMetricsAt(idx),compareMetrics(casual,idx),"FROZEN (Reference)",compareMetricsLabel,new Color(228,238,251),new Color(242,233,248)),BorderLayout.CENTER);metricsHost.revalidate();metricsHost.repaint(); updateComparisonStats(); updateControllerRationale();
            if(differentStartingBasis){
                decision.setText("ENTRY-POINT COUNTERFACTUAL — DIFFERENT STARTING CARD POSITION  •  DIRECT HAND-DECISION COMPARISON N/A");
                decision.setBackground(new Color(250,247,225)); decision.setForeground(new Color(120,78,10));
            }else if(idx<frozenCount() && idx<casual.size()){
                CompareHand c=casual.get(idx);String fa=frozenActionAt(idx), ca=firstCasualAction(c);
                if(!fa.isEmpty()&&!ca.isEmpty()&&fa.equals(ca)){
                    decision.setText("✓ SAME PLAYER DECISION AT THIS HAND POSITION  •  "+fa);
                    decision.setBackground(new Color(226,244,232)); decision.setForeground(new Color(20,104,57));
                }else if(!fa.isEmpty()&&!ca.isEmpty()){
                    decision.setText("✕ "+targetName.toUpperCase(Locale.ROOT)+": "+fa+" → "+ca+" (DIFFERS FROM FROZEN)");
                    decision.setBackground(new Color(255,232,228)); decision.setForeground(new Color(170,38,32));
                }else{
                    decision.setText("PLAYER DECISION LABEL UNAVAILABLE AT THIS HAND POSITION");
                    decision.setBackground(new Color(242,242,242)); decision.setForeground(new Color(80,80,80));
                }
            }else{
                if(statStayView!=null&&idx>=statStayView.exitHand&&statStayView.stayPath!=null&&idx+1<statStayView.stayPath.size()){
                    decision.setText("STAT-WATCHING CHOSEN JOURNEY ENDED H"+statStayView.exitHand+" AT £"+money(statStayView.exitBank)+"  •  RIGHT PANE NOW SHOWS RETAINED STAY-AT-TABLE BANKROLL PATH ONLY");
                    decision.setBackground(new Color(250,247,225)); decision.setForeground(new Color(120,78,10));
                    evidence.setText("<html><div style='text-align:center'>POST-EXIT COUNTERFACTUAL  •  Frozen continues graphically  •  Stat-Watching stay path is retained bankroll evidence only<br>NO post-exit cards/actions/wagers are inferred</div></html>");
                }else if(ultimateStayView!=null&&idx>=ultimateStayView.hands){
                    String stay=(ultimateStayView.stayPath!=null&&!ultimateStayView.stayPath.isEmpty())?"  •  RIGHT PANE SHOWS RETAINED STAY COUNTERFACTUAL WHERE AVAILABLE":"  •  STAY COUNTERFACTUAL N/A — NOT RETAINED";
                    decision.setText("ULTIMATE AI EXITED H"+ultimateStayView.hands+" AT £"+money(ultimateStayView.aiBank)+stay);
                    decision.setBackground(new Color(250,247,225)); decision.setForeground(new Color(120,78,10));
                    evidence.setText("<html><div style='text-align:center'>ULTIMATE AI EXIT BOUNDARY  •  Frozen may continue graphically  •  exit bankroll remains the genuine Ultimate outcome<br>NO post-exit controller evidence is invented</div></html>");
                }else{
                    decision.setText("ONE JOURNEY HAS ENDED  •  no direct hand-position comparison");
                    decision.setBackground(new Color(242,242,242)); decision.setForeground(new Color(85,85,85));
                }
            }
        }
    }

    class HybridCompareWindow extends JFrame{
        Session hybrid;List<CompareHand>frozen;CObserved observedSource;int idx;JLabel pos=new JLabel(),driftBadge=new JLabel(),evidence=new JLabel(),decision=new JLabel();JPanel left=new JPanel(),right=new JPanel();
        JLabel streakDecision=new JLabel(),streakWager=new JLabel(); JPanel statsHost=new JPanel(),metricsHost=new JPanel(),notesHost=new JPanel(); JButton prevButton,nextButton;
        HybridCompareWindow(Session h,List<CompareHand>f,int start){
            super("Frozen vs Hybrid — "+sessionDisplayTitle(h));
            setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
            addWindowListener(new WindowAdapter(){ @Override public void windowClosed(WindowEvent e){ BlackjackSessionReplay.this.setVisible(true); BlackjackSessionReplay.this.toFront(); BlackjackSessionReplay.this.requestFocus(); } });
            hybrid=h;frozen=f;idx=Math.min(Math.max(0,start),Math.max(h.hands.size(),f.size())-1);
            try{observedSource=observed(loadedOutputText,h.id);}catch(Exception ignored){}
            Rectangle usable=GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();setSize(Math.max(1180,usable.width-20),Math.max(720,usable.height-20));setLocationRelativeTo(BlackjackSessionReplay.this);setLayout(new BorderLayout(6,1));getContentPane().setBackground(new Color(245,248,250));
            JPanel top=new JPanel();top.setLayout(new BoxLayout(top,BoxLayout.Y_AXIS));JLabel hd=new JLabel("SOURCE-SUPPORTED JUXTAPOSED REPLAY  •  FROZEN BASELINE vs OBSERVED HYBRID  •  SAME FORMAL CAPTURED SOURCE",SwingConstants.CENTER);hd.setFont(new Font("SansSerif",Font.BOLD,16));hd.setAlignmentX(Component.CENTER_ALIGNMENT);top.add(hd);
            JLabel sub=new JLabel("Frozen is reconstructed independently from the formal starting cardstream; Hybrid is the authoritative observed journey. Card consumption may diverge after different decisions.",SwingConstants.CENTER);sub.setFont(new Font("SansSerif",Font.PLAIN,12));sub.setAlignmentX(Component.CENTER_ALIGNMENT);top.add(sub);evidence.setFont(new Font("SansSerif",Font.BOLD,12));evidence.setAlignmentX(Component.CENTER_ALIGNMENT);top.add(evidence);add(top,BorderLayout.NORTH);
            JPanel body=new JPanel(new GridLayout(1,2,4,0));body.setBackground(Color.BLACK);left.setLayout(new BoxLayout(left,BoxLayout.Y_AXIS));right.setLayout(new BoxLayout(right,BoxLayout.Y_AXIS));
            // v15.10.135-UAT: no nested per-player scrolling; both evidence panels always share the visible canvas.
            body.add(left);body.add(right);
            notesHost.setLayout(new BorderLayout());notesHost.setOpaque(false);notesHost.setPreferredSize(new Dimension(100,56));notesHost.setMaximumSize(new Dimension(Integer.MAX_VALUE,180));
            metricsHost.setLayout(new BorderLayout());metricsHost.setOpaque(false);
            JPanel evidenceBelowCards=new JPanel();evidenceBelowCards.setLayout(new BoxLayout(evidenceBelowCards,BoxLayout.Y_AXIS));evidenceBelowCards.setOpaque(false);evidenceBelowCards.add(notesHost);evidenceBelowCards.add(metricsHost);
            JPanel centerWrap=new JPanel(new BorderLayout());centerWrap.setBackground(new Color(238,240,242));centerWrap.add(body,BorderLayout.CENTER);centerWrap.add(evidenceBelowCards,BorderLayout.SOUTH);add(centerWrap,BorderLayout.CENTER);
            JPanel south=new JPanel();south.setLayout(new BoxLayout(south,BoxLayout.Y_AXIS));statsHost.setLayout(new BoxLayout(statsHost,BoxLayout.Y_AXIS));statsHost.setOpaque(false);south.add(statsHost);
            decision.setOpaque(true);decision.setHorizontalAlignment(SwingConstants.CENTER);decision.setFont(new Font("SansSerif",Font.BOLD,12));decision.setBorder(new EmptyBorder(0,5,0,5));decision.setAlignmentX(Component.CENTER_ALIGNMENT);decision.setPreferredSize(new Dimension(100,16));decision.setMaximumSize(new Dimension(Integer.MAX_VALUE,16));south.add(decision);
            JPanel nav=new JPanel(new GridBagLayout());prevButton=new JButton("◀ PREVIOUS");nextButton=new JButton("NEXT ▶");prevButton.addActionListener(e->{if(idx>0){idx--;showIt();}});nextButton.addActionListener(e->{int last=Math.max(hybrid.hands.size(),frozen.size());if(idx<last){idx++;showIt();}});stylePointerBadge(driftBadge);
            GridBagConstraints ng=new GridBagConstraints();ng.gridy=0;ng.weightx=.5;ng.anchor=GridBagConstraints.EAST;ng.insets=new Insets(0,0,0,120);nav.add(prevButton,ng);ng.gridx=1;ng.anchor=GridBagConstraints.WEST;ng.insets=new Insets(0,120,0,0);nav.add(nextButton,ng);
            nav.setPreferredSize(new Dimension(100,30));nav.setMaximumSize(new Dimension(Integer.MAX_VALUE,30));south.add(nav);
            JLabel legend=new JLabel("Yellow border = differing observed allocation   •   Dark-orange outline = dealer hole card   •   Blue badge = captured stream draw order   •   CARD POSITION = local pointer drift vs Frozen",SwingConstants.CENTER);legend.setFont(new Font("SansSerif",Font.BOLD,10));legend.setAlignmentX(Component.CENTER_ALIGNMENT);legend.setMaximumSize(new Dimension(Integer.MAX_VALUE,12));south.add(legend);add(south,BorderLayout.SOUTH);
            showIt();
        }
        JLabel cl(String s,int sz,boolean bold){JLabel x=new JLabel(s,SwingConstants.CENTER);x.setFont(new Font("SansSerif",bold?Font.BOLD:Font.PLAIN,sz));x.setAlignmentX(Component.CENTER_ALIGNMENT);x.setBorder(new EmptyBorder(2,6,2,6));return x;}
        JPanel actionResultBlock(String actionText,String resultText){
            JPanel box=new JPanel(); box.setOpaque(false); box.setLayout(new BoxLayout(box,BoxLayout.Y_AXIS));
            JLabel a=new JLabel(actionText,SwingConstants.CENTER); a.setFont(new Font("SansSerif",Font.BOLD,13)); a.setAlignmentX(Component.CENTER_ALIGNMENT); a.setBorder(new EmptyBorder(0,4,0,4));
            JLabel r=new JLabel(resultText,SwingConstants.CENTER); r.setFont(new Font("SansSerif",Font.BOLD,16)); r.setAlignmentX(Component.CENTER_ALIGNMENT); r.setBorder(new EmptyBorder(0,4,0,4));
            box.add(a); box.add(r);
            box.setPreferredSize(new Dimension(100,38)); box.setMaximumSize(new Dimension(Integer.MAX_VALUE,38));
            return box;
        }
        String cardKey(String raw){return raw==null?"":raw.trim().toUpperCase(Locale.ROOT).replace(" ","");}
        boolean[] differingCards(List<String> cards,List<String> other){boolean[]diff=new boolean[cards.size()];boolean[]used=new boolean[other==null?0:other.size()];for(int i=0;i<cards.size();i++){String k=cardKey(cards.get(i));int found=-1;for(int j=0;j<used.length;j++)if(!used[j]&&k.equals(cardKey(other.get(j)))){found=j;break;}if(found>=0)used[found]=true;else diff[i]=true;}return diff;}
        JPanel cardRow(String label,List<String>cards,List<String>other,List<Integer>seqs){JPanel outer=new JPanel();outer.setOpaque(false);outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));outer.add(cl(label,14,true));JPanel row=new JPanel(new FlowLayout(FlowLayout.CENTER,6,1));row.setOpaque(false);boolean[]diff=differingCards(cards,other);for(int i=0;i<cards.size();i++){int q=(seqs!=null&&i<seqs.size())?seqs.get(i):0;boolean hole=label!=null&&label.startsWith("DEALER")&&i==1;row.add(new CompactCardPanel(cards.get(i),diff[i],q,hole));}outer.add(row);outer.setPreferredSize(new Dimension(100,124));outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,124));return outer;}
        List<Integer> hybridPlayerSeq(Hand h){List<Integer>r=new ArrayList<>();if(h==null||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;List<CCard>pool=new ArrayList<>();for(CCard c:all)if(c.role.toUpperCase(Locale.ROOT).startsWith("PLAYER"))pool.add(c);boolean[]used=new boolean[pool.size()];for(String raw:h.player){int hit=-1;for(int i=0;i<pool.size();i++)if(!used[i]&&cardKey(raw).equals(cardKey(pool.get(i).toString()))){hit=i;break;}if(hit>=0){used[hit]=true;r.add(pool.get(hit).seq);}else r.add(0);}return r;}
        List<Integer> hybridDealerSeq(Hand h){List<Integer>r=new ArrayList<>();if(h==null||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;CCard up=null,hole=null;List<CCard>draws=new ArrayList<>();for(CCard c:all){String role=c.role.toUpperCase(Locale.ROOT);if(role.contains("DEALER UPCARD"))up=c;else if(role.contains("DEALER HOLE"))hole=c;else if(role.startsWith("DEALER DRAW"))draws.add(c);}if(up!=null)r.add(up.seq);else r.add(0);if(!h.dealerHidden.isEmpty()){if(hole!=null)r.add(hole.seq);else r.add(0);for(int i=1;i<h.dealerHidden.size();i++)r.add(i-1<draws.size()?draws.get(i-1).seq:0);}return r;}
        JPanel splitBlock(List<List<String>>branches,List<List<Integer>>seqs,List<List<String>>other){
            JPanel box=new JPanel(new GridLayout(1,Math.max(1,branches.size()),8,0));box.setOpaque(false);
            for(int z=0;z<branches.size();z++){
                List<String>b=branches.get(z);List<String>o=z<other.size()?other.get(z):Collections.emptyList();List<Integer>sq=z<seqs.size()?seqs.get(z):Collections.emptyList();
                // Extreme split branch: recover horizontal room locally instead of wrapping a fifth card.
                // Four-card and ordinary rows retain the accepted 66px/6px presentation.
                if(b.size()>=5){
                    JPanel outer=new JPanel();outer.setOpaque(false);outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));outer.add(cl("SPLIT "+(char)('A'+z)+"  •  TOTAL "+cardTotal(b),14,true));
                    JPanel row=new JPanel(new FlowLayout(FlowLayout.CENTER,1,1));row.setOpaque(false);boolean[]diff=differingCards(b,o);
                    for(int i=0;i<b.size();i++){int q=(sq!=null&&i<sq.size())?sq.get(i):0;row.add(new CompactCardPanel(b.get(i),diff[i],q,false,60));}
                    outer.add(row);outer.setPreferredSize(new Dimension(100,124));outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,124));box.add(outer);
                }else box.add(cardRow("SPLIT "+(char)('A'+z)+"  •  TOTAL "+cardTotal(b),b,o,sq));
            }
            box.setMaximumSize(new Dimension(Integer.MAX_VALUE,130));return box;
        }
        List<List<String>> splitActionTrails(List<String> actions,List<List<String>> branches){
            List<List<String>> out=new ArrayList<>(); if(branches==null)return out;
            int ai=0; if(actions!=null&&!actions.isEmpty()&&"SPLIT".equalsIgnoreCase(actions.get(0)))ai=1;
            for(List<String> branch:branches){
                List<String> trail=new ArrayList<>(); int drawsNeeded=Math.max(0,(branch==null?0:branch.size())-2),draws=0;
                while(actions!=null&&ai<actions.size()){
                    String a=actions.get(ai++); if("SPLIT".equalsIgnoreCase(a))continue; trail.add(a);
                    if("HIT".equalsIgnoreCase(a)){draws++; if(draws>=drawsNeeded){if(ai<actions.size()&&"STAND".equalsIgnoreCase(actions.get(ai)))trail.add(actions.get(ai++));break;}}
                    else if("DOUBLE".equalsIgnoreCase(a)){draws++;break;}
                    else if("STAND".equalsIgnoreCase(a)||"NATURAL".equalsIgnoreCase(a))break;
                }
                if(trail.isEmpty())trail.add("—"); out.add(trail);
            }
            return out;
        }
        JPanel splitActionResultBlock(List<String> actions,List<List<String>> branches,String resultText){
            JPanel box=new JPanel();box.setOpaque(false);box.setLayout(new BoxLayout(box,BoxLayout.Y_AXIS));
            List<List<String>> trails=splitActionTrails(actions,branches);
            JPanel row=new JPanel(new GridLayout(1,Math.max(1,branches.size()),8,0));row.setOpaque(false);
            for(int i=0;i<branches.size();i++){String t=i<trails.size()?String.join(" → ",trails.get(i)):"—";JLabel a=new JLabel("SPLIT "+(char)('A'+i)+": "+t,SwingConstants.CENTER);a.setFont(new Font("SansSerif",Font.BOLD,12));row.add(a);}
            String mainResult=resultText, settlementLine="";
            int si=resultText.indexOf("  •  ✓ DISPLAY SETTLEMENT");
            if(si<0) si=resultText.indexOf("  •  ⚠ DISPLAY / BANKROLL");
            if(si>=0){mainResult=resultText.substring(0,si);settlementLine=resultText.substring(si+5);}
            JLabel r=new JLabel(mainResult,SwingConstants.CENTER);r.setFont(new Font("SansSerif",Font.BOLD,16));r.setAlignmentX(Component.CENTER_ALIGNMENT);
            box.add(row);box.add(r);
            if(!settlementLine.isBlank()){JLabel sc=new JLabel(settlementLine,SwingConstants.CENTER);sc.setFont(new Font("SansSerif",Font.BOLD,12));sc.setAlignmentX(Component.CENTER_ALIGNMENT);box.add(sc);}
            int bh=settlementLine.isBlank()?38:54;box.setPreferredSize(new Dimension(100,bh));box.setMaximumSize(new Dimension(Integer.MAX_VALUE,bh));return box;
        }
        int sourceRuleBoundaryIndex(){
            int n=Math.min(frozen.size(),hybrid.hands.size());
            for(int i=0;i<n;i++){
                CompareHand f=frozen.get(i); Hand h=hybrid.hands.get(i);
                if(!sameOpeningState(h,f)) continue;
                String fa=firstCasualAction(f),ha=firstFrozenAction(h);
                if(soft17SourceRuleDivergence(f,h,fa,ha)) return i;
            }
            return -1;
        }
        boolean directCardComparisonEnabled(int i){
            // v15.10.85-UAT: yellow allocation comparison remains active for every
            // hand position where BOTH source-supported journeys still exist.
            // A dealer/source-rule boundary remains an explanatory/causal boundary;
            // it must not suppress the observational card-allocation comparison.
            return i>=0 && i<frozen.size() && i<hybrid.hands.size();
        }
        JPanel boundaryDealerRow(Hand h){
            JPanel outer=new JPanel();outer.setOpaque(false);outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));
            List<String>full=new ArrayList<>();full.add(h.dealerUp);full.addAll(h.dealerHidden);
            outer.add(cl("DEALER — OBSERVED  •  FINAL TOTAL "+cardTotal(full),14,true));
            JPanel row=new JPanel(new FlowLayout(FlowLayout.CENTER,7,1));row.setOpaque(false);
            List<Integer>seq=hybridDealerSeq(h);
            List<String>opening=new ArrayList<>();opening.add(h.dealerUp);if(h.dealerHidden!=null&&!h.dealerHidden.isEmpty())opening.add(h.dealerHidden.get(0));
            for(int i=0;i<opening.size();i++) row.add(new CompactCardPanel(opening.get(i),false,i<seq.size()?seq.get(i):0,i==1));
            JPanel cut=new JPanel();cut.setOpaque(false);cut.setLayout(new BoxLayout(cut,BoxLayout.Y_AXIS));cut.setBorder(BorderFactory.createMatteBorder(0,2,0,0,new Color(137,82,0)));
            JLabel a=cl("COMPARABILITY",10,true);a.setForeground(new Color(137,82,0));
            JLabel b=cl("BOUNDARY",10,true);b.setForeground(new Color(137,82,0));
            JLabel c=cl("Frozen stops",10,false);c.setForeground(new Color(137,82,0));
            JLabel d=cl("on soft 17",10,false);d.setForeground(new Color(137,82,0));
            cut.add(a);cut.add(b);cut.add(c);cut.add(d);cut.setPreferredSize(new Dimension(108,92));cut.setMaximumSize(new Dimension(108,92));row.add(cut);
            if(h.dealerHidden!=null&&h.dealerHidden.size()>1){
                for(int i=1;i<h.dealerHidden.size();i++){
                    int qi=(i+1)<seq.size()?seq.get(i+1):0;
                    row.add(new CompactCardPanel(h.dealerHidden.get(i),true,qi));
                }
            }
            outer.add(row);
            JLabel note=cl("Left of boundary = directly comparable dealer opening   •   Right = observed source-only dealer continuation",11,true);
            note.setForeground(new Color(137,82,0));outer.add(note);
            outer.setPreferredSize(new Dimension(100,142));outer.setMaximumSize(new Dimension(Integer.MAX_VALUE,142));return outer;
        }
        void fillFrozen(JPanel p,int i){
            p.removeAll();p.setBackground(new Color(230,242,247));CompareHand f=i<frozen.size()?frozen.get(i):null;Hand h=i<hybrid.hands.size()?hybrid.hands.get(i):null;
            if(f==null){p.add(Box.createVerticalStrut(45));p.add(cl("FROZEN SOURCE-SUPPORTED TRACE ENDED",18,true));p.add(cl("No unsupported cards are invented beyond this point.",12,false));p.revalidate();p.repaint();return;}
            boolean compareCards=directCardComparisonEnabled(i);List<String>hd=new ArrayList<>(),hp=new ArrayList<>();
            if(h!=null&&compareCards){hd.add(h.dealerUp);hd.addAll(h.dealerHidden);hp.addAll(h.player);}else{hd.addAll(f.dealer);hp.addAll(f.player);}
            p.add(Box.createVerticalStrut(2));p.add(identityHeader("FROZEN","FROZEN — BASELINE COUNTERFACTUAL"));p.add(cl("HAND "+f.number+" / "+frozen.size(),15,true));p.add(cl("Bankroll £"+money(f.before)+" → £"+money(f.after)+"     Wager £"+money(f.wager)+"     Exposure £"+money(f.committed),15,true));
            p.add(cardRow("DEALER  •  TOTAL "+cardTotal(f.dealer),f.dealer,hd,f.dealerSeq));
            if(f.branches.size()>1){List<List<String>>other=new ArrayList<>();if(h!=null&&h.split&&compareCards){other.add(h.splitA);other.add(h.splitB);}else other.addAll(f.branches);p.add(splitBlock(f.branches,f.branchSeq,other));}
            else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(f.player),f.player,hp,f.playerSeq));
            // Juxtaposed Hybrid comparison intentionally omits duplicate Actions/RESULT lines.
            p.revalidate();p.repaint();
        }
        JPanel hybridMindsetPanel(Hand h){
            JPanel box=new JPanel(new BorderLayout(6,0));box.setBackground(new Color(248,242,251));
            List<String> lines=new ArrayList<>();
            for(String x:h.wagerMindset)lines.add("WAGER REASON — "+x);
            for(String x:h.actionMindset)lines.add("ACTION THOUGHT — "+x);
            for(String x:h.postHandMindset)lines.add("AFTER-HAND THOUGHT — "+x);
            if(lines.isEmpty()){box.setOpaque(false);return box;}
            box.setBorder(new CompoundBorder(new LineBorder(new Color(126,93,145),1),new EmptyBorder(2,8,1,8)));
            JLabel title=new JLabel("HYBRID PLAYER NOTES — RECORDED EVIDENCE",SwingConstants.CENTER);title.setFont(new Font("SansSerif",Font.BOLD,10));title.setForeground(new Color(88,55,108));title.setBorder(new EmptyBorder(0,0,0,0));box.add(title,BorderLayout.NORTH);
            JTextArea q=new JTextArea(String.join("\n",lines));
            q.setEditable(false);q.setLineWrap(true);q.setWrapStyleWord(true);q.setOpaque(false);
            q.setFont(new Font("SansSerif",Font.PLAIN,10));q.setForeground(new Color(70,55,78));q.setBorder(new EmptyBorder(0,3,0,3));
            int wrapWidth=Math.max(720,getWidth()>0?getWidth()-70:1100);
            q.setSize(new Dimension(wrapWidth,Short.MAX_VALUE));
            Dimension textPref=q.getPreferredSize();
            int noteH=Math.max(48,Math.min(170,textPref.height+24));
            box.add(q,BorderLayout.CENTER);
            box.setPreferredSize(new Dimension(100,noteH));box.setMaximumSize(new Dimension(Integer.MAX_VALUE,noteH));return box;
        }
        String escapeHtml(String x){return x.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;");}
        void fillHybrid(JPanel p,int i){
            p.removeAll();p.setBackground(new Color(244,235,249));Hand h=i<hybrid.hands.size()?hybrid.hands.get(i):null;CompareHand f=i<frozen.size()?frozen.get(i):null;
            if(h==null){p.add(Box.createVerticalStrut(45));p.add(cl("OBSERVED HYBRID JOURNEY ENDED",18,true));p.revalidate();p.repaint();return;}
            boolean boundary=false;if(f!=null&&sameOpeningState(h,f)){String fa=firstCasualAction(f),ha=firstFrozenAction(h);boundary=soft17SourceRuleDivergence(f,h,fa,ha);}
            boolean compareCards=directCardComparisonEnabled(i);List<String>fd=new ArrayList<>(),fp=new ArrayList<>();if(f!=null&&compareCards){fd.addAll(f.dealer);fp.addAll(f.player);}else{List<String>all=new ArrayList<>();all.add(h.dealerUp);all.addAll(h.dealerHidden);fd.addAll(all);fp.addAll(h.player);}
            List<String>hd=new ArrayList<>();hd.add(h.dealerUp);hd.addAll(h.dealerHidden);
            p.add(Box.createVerticalStrut(2));p.add(identityHeader("HYBRID","HYBRID — OBSERVED LIVE JOURNEY"));p.add(cl("HAND "+h.number+" / "+hybrid.hands.size(),15,true));p.add(cl("Bankroll £"+money(h.researchStart())+" → £"+money(h.researchEnd())+"     Wager £"+h.wager+"     Exposure £"+h.committed,15,true));
            if(boundary){
                p.add(boundaryDealerRow(h));
            }else p.add(cardRow("DEALER  •  TOTAL "+cardTotal(hd),hd,fd,hybridDealerSeq(h)));
            if(h.split){List<List<String>>br=new ArrayList<>();br.add(h.splitA);br.add(h.splitB);List<List<Integer>>sq=new ArrayList<>();sq.add(Collections.emptyList());sq.add(Collections.emptyList());List<List<String>>other=(f!=null&&f.branches.size()>1&&compareCards)?f.branches:br;p.add(splitBlock(br,sq,other));}
            else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(h.player),h.player,fp,hybridPlayerSeq(h)));
            // Juxtaposed Hybrid comparison intentionally omits duplicate Actions/RESULT lines.
            p.revalidate();p.repaint();
        }
        boolean sameDealerOpening(CompareHand f,Hand h){if(f==null||h==null||f.dealer==null||f.dealer.size()<2||h.dealerHidden==null||h.dealerHidden.isEmpty())return false;return cardKey(f.dealer.get(0)).equals(cardKey(h.dealerUp))&&cardKey(f.dealer.get(1)).equals(cardKey(h.dealerHidden.get(0)));}
        boolean frozenDealerOpeningIsSoft17(CompareHand f){if(f==null||f.dealer==null||f.dealer.size()<2)return false;List<String>x=f.dealer.subList(0,2);if(cardTotal(x)!=17)return false;boolean ace=false;int raw=0;for(String c:x){String k=cardKey(c).replaceAll("[♠♥♦♣SHDC]$","");if(k.equals("A")){ace=true;raw+=11;}else if(k.matches("10|J|Q|K"))raw+=10;else try{raw+=Integer.parseInt(k);}catch(Exception ignored){}}return ace&&raw==17;}
        boolean observedDealerDrewAfterOpening(Hand h){return h!=null&&h.dealerHidden!=null&&h.dealerHidden.size()>1;}
        boolean soft17SourceRuleDivergence(CompareHand f,Hand h,String fa,String ha){return "STAND".equals(fa)&&"STAND".equals(ha)&&sameDealerOpening(f,h)&&frozenDealerOpeningIsSoft17(f)&&f.dealer.size()==2&&observedDealerDrewAfterOpening(h);}
        JPanel summarySide(String title,boolean frozenSide){
            int n=Math.min(frozen.size(),hybrid.hands.size()),same=0,diff=0,longSame=0,longDiff=0,curSame=0,curDiff=0;
            double samePL=0,diffPL=0,sameExp=0,diffExp=0;
            for(int i=0;i<n;i++){CompareHand f=frozen.get(i);Hand hh=hybrid.hands.get(i);boolean sp=sameOpeningState(hh,f);double pl=frozenSide?(f.after-f.before):(hh.researchEnd()-hh.researchStart());double ex=frozenSide?f.committed:parseMoney(hh.committed);if(sp){same++;samePL+=pl;sameExp+=ex;curSame++;curDiff=0;longSame=Math.max(longSame,curSame);}else{diff++;diffPL+=pl;diffExp+=ex;curDiff++;curSame=0;longDiff=Math.max(longDiff,curDiff);}}
            JPanel p=new JPanel();p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS));p.setBackground(frozenSide?new Color(232,245,249):new Color(246,237,250));p.setBorder(new EmptyBorder(12,18,12,18));p.add(cl("<html><div style='text-align:center'>"+title+"</div></html>",17,true));p.add(Box.createVerticalStrut(10));
            String[] rows={"SAME DECISION-POINT START HANDS   "+same,"P/L ON SAME START-HAND POINTS   "+signedMoney(samePL),"EXPOSURE ON SAME START-HAND POINTS   £"+money(sameExp),"LONGEST SAME START-HAND RUN   "+longSame+" HAND"+(longSame==1?"":"S")," ","DIFFERENT DECISION-POINT START HANDS   "+diff,"P/L ON DIFFERENT START-HAND POINTS   "+signedMoney(diffPL),"EXPOSURE ON DIFFERENT START-HAND POINTS   £"+money(diffExp),"LONGEST DIFFERENT START-HAND RUN   "+longDiff+" HAND"+(longDiff==1?"":"S")};
            for(String r:rows){JLabel x=cl(r,13,!r.equals(" "));x.setAlignmentX(Component.CENTER_ALIGNMENT);p.add(x);}return p;
        }
        JPanel finalComparisonSummary(){
            int n=Math.min(frozen.size(),hybrid.hands.size()),same=0,diff=0,sameAction=0;
            for(int i=0;i<n;i++){CompareHand f=frozen.get(i);Hand hh=hybrid.hands.get(i);boolean sp=sameOpeningState(hh,f);if(sp)same++;else diff++;String fa=firstCasualAction(f),ha=firstFrozenAction(hh);if(sp&&!fa.isEmpty()&&fa.equals(ha))sameAction++;}
            int fp=frozen.isEmpty()?0:compareHandEndPointer(frozen.get(frozen.size()-1));int hp=hybrid.hands.isEmpty()?0:observedHandEndPointer(hybrid,hybrid.hands.get(hybrid.hands.size()-1).number);int drift=(fp>0&&hp>0)?hp-fp:0;
            JPanel p=new JPanel(new GridLayout(1,5,1,0));p.setBackground(new Color(218,223,228));p.setBorder(new EmptyBorder(3,8,3,8));String[] heads={"COMPARABLE HANDS","START-HAND POINTS","ACTION MATCH / SAME POINTS","CARDS CONSUMED","FINAL CARD POSITION"};String[] vals={String.valueOf(n),same+" SAME / "+diff+" DIFFERENT",sameAction+" / "+same,fp+" FROZEN / "+hp+" HYBRID",(fp>0&&hp>0?((drift>0?"+":"")+drift):"N/A")};for(int i=0;i<heads.length;i++){JPanel c=new JPanel();c.setBackground(Color.WHITE);c.setLayout(new BoxLayout(c,BoxLayout.Y_AXIS));JLabel a=cl(heads[i],10,true),b=cl(vals[i],14,true);a.setForeground(new Color(30,63,101));b.setForeground(new Color(15,55,82));c.add(a);c.add(b);p.add(c);}p.setPreferredSize(new Dimension(100,62));return p;
        }
        void showFinalSummary(){
            left.removeAll();right.removeAll();left.add(summarySide("FROZEN — START-HAND DECISION-POINT SUMMARY",true));right.add(summarySide("HYBRID — START-HAND DECISION-POINT SUMMARY",false));left.revalidate();left.repaint();right.revalidate();right.repaint();notesHost.removeAll();notesHost.setVisible(false);metricsHost.removeAll();metricsHost.add(finalComparisonSummary(),BorderLayout.CENTER);metricsHost.revalidate();metricsHost.repaint();statsHost.removeAll();statsHost.revalidate();statsHost.repaint();decision.setText("END-OF-REPLAY SUMMARY  •  cumulative evidence separated by SAME vs DIFFERENT start-hand decision points");decision.setBackground(new Color(226,244,232));decision.setForeground(new Color(20,104,57));evidence.setText("Cumulative comparison across "+Math.min(frozen.size(),hybrid.hands.size())+" mutually supported hand positions   •   journey statistics are not repeated");prevButton.setEnabled(true);nextButton.setEnabled(false);
        }
        void updateComparisonStats(){
            statsHost.removeAll();
            if(idx<frozen.size()&&idx<hybrid.hands.size()){
                CompareHand f=frozen.get(idx);Hand h=hybrid.hands.get(idx);String fa=firstCasualAction(f),ha=firstFrozenAction(h);
                boolean samePoint=sameOpeningState(h,f);
                boolean sameWager=Math.abs(f.wager-parseMoney(h.wager))<0.005;
                boolean sameAction=!fa.isEmpty()&&!ha.isEmpty()&&fa.equals(ha);
                int streakHands=0;double runF=0,runH=0;
                for(int j=idx;j>=0;j--){
                    if(j>=frozen.size()||j>=hybrid.hands.size())break;
                    CompareHand fj=frozen.get(j);Hand hj=hybrid.hands.get(j);
                    if(sameOpeningState(hj,fj)!=samePoint)break;
                    streakHands++;runF+=fj.after-fj.before;runH+=hj.researchEnd()-hj.researchStart();
                }
                statsHost.add(currentDecisionPointStreakBox("HYBRID",samePoint,sameWager,sameAction,streakHands,runF,runH,pos,driftBadge,false));
            }
            statsHost.revalidate();statsHost.repaint();
        }
        void showIt(){
            int last=Math.max(hybrid.hands.size(),frozen.size());
            if(idx>=last){showFinalSummary();return;}
            prevButton.setEnabled(idx>0);nextButton.setEnabled(true);
            fillFrozen(left,idx);fillHybrid(right,idx);
            notesHost.removeAll();
            if(idx<hybrid.hands.size()){
                JPanel mindset=hybridMindsetPanel(hybrid.hands.get(idx));
                if(mindset.getComponentCount()>0){int nh=Math.max(56,mindset.getPreferredSize().height);notesHost.setPreferredSize(new Dimension(100,nh));notesHost.setMaximumSize(new Dimension(Integer.MAX_VALUE,nh));notesHost.add(mindset,BorderLayout.CENTER);notesHost.setVisible(true);}else{notesHost.setVisible(false);}
            }else notesHost.setVisible(false);
            notesHost.revalidate();notesHost.repaint();
            int fp=idx<frozen.size()?compareHandEndPointer(frozen.get(idx)):0;int hp=idx<hybrid.hands.size()?observedHandEndPointer(hybrid,hybrid.hands.get(idx).number):0;pos.setText("HAND POSITION "+(idx+1));driftBadge.setText(localDriftText(hp,fp));metricsHost.removeAll();metricsHost.add(storyMetricsStrip(compareMetrics(frozen,idx),observedMetrics(hybrid,idx),"FROZEN (Reference)","HYBRID (Live Replay)",new Color(228,238,251),new Color(230,246,233)),BorderLayout.CENTER);metricsHost.revalidate();metricsHost.repaint();updateComparisonStats();evidence.setText("Frozen source-supported hands: "+frozen.size()+"   •   Observed Hybrid hands: "+hybrid.hands.size()+"   •   Frozen remains the fixed reference baseline");
            int boundary=sourceRuleBoundaryIndex();
            if(idx<frozen.size()&&idx<hybrid.hands.size()){
                CompareHand f=frozen.get(idx);Hand h=hybrid.hands.get(idx);String fa=firstCasualAction(f),ha=firstFrozenAction(h);
                boolean same=!fa.isEmpty()&&!ha.isEmpty()&&fa.equals(ha);
                if(boundary>=0&&idx==boundary&&soft17SourceRuleDivergence(f,h,fa,ha)){
                    decision.setText("⚠ SAME PLAYER DECISION: STAND  •  dealer/source rule diverges here: observed source hit soft 17 while Frozen S17 stands  •  later card drift is observational only");
                    decision.setBackground(new Color(255,244,214));decision.setForeground(new Color(137,82,0));
                }else if(boundary>=0&&idx>boundary){
                    String local=(!fa.isEmpty()&&!ha.isEmpty())?(same?("SAME PLAYER DECISION • "+fa):("DIFFERENT PLAYER DECISION • FROZEN "+fa+" vs HYBRID "+ha)):"PLAYER DECISION LABEL UNAVAILABLE";
                    decision.setText(local+"  •  post-boundary hand-position observation; later card differences are not attributed solely to player decisions");
                    decision.setBackground(new Color(255,244,214));decision.setForeground(new Color(137,82,0));
                }else if(!fa.isEmpty()&&!ha.isEmpty()&&same){
                    decision.setText("✓ SAME PLAYER DECISION AT THIS HAND POSITION  •  "+fa);decision.setBackground(new Color(226,244,232));decision.setForeground(new Color(20,104,57));
                }else if(!fa.isEmpty()&&!ha.isEmpty()){
                    decision.setText("✕ DIFFERENT PLAYER DECISION AT THIS HAND POSITION  •  FROZEN "+fa+"   vs   HYBRID "+ha);decision.setBackground(new Color(255,232,228));decision.setForeground(new Color(170,38,32));
                }else{decision.setText("PLAYER DECISION LABEL UNAVAILABLE AT THIS HAND POSITION");decision.setBackground(new Color(242,242,242));decision.setForeground(new Color(80,80,80));}
            }else{decision.setText("ONE JOURNEY HAS ENDED  •  no hand-position decision comparison");decision.setBackground(new Color(242,242,242));decision.setForeground(new Color(85,85,85));}
        }
    }

    static String compareSplitResultText(CompareHand h){
        String base="RESULT: "+friendlyOutcome(h.result);
        if(h==null||h.branches==null||h.branches.size()<2)return base;
        int dt=cardTotal(h.dealer);
        List<String> child=new ArrayList<>();
        for(int i=0;i<h.branches.size();i++){
            int pt=cardTotal(h.branches.get(i));
            String r=pt>21?"LOSS":(dt>21||pt>dt)?"WIN":pt<dt?"LOSS":"PUSH";
            child.add("SPLIT "+(char)('A'+i)+" "+r);
        }
        return base+"  •  "+String.join(" / ",child);
    }

    static int cardTotal(List<String> xs){int t=0,a=0;for(String raw:xs){String x=raw.replaceAll("[♠♥♦♣SHDC]$","");int v;if(x.equals("A")){v=11;a++;}else if(x.matches("10|J|Q|K"))v=10;else try{v=Integer.parseInt(x);}catch(Exception e){continue;}t+=v;}while(t>21&&a-->0)t-=10;return t;}

    static class Session { String id=""; String summary=""; String architecture=""; String sessionName=""; String sessionClassification=""; String platform=""; String envCapture="UNKNOWN", envPlatformStart="UNKNOWN", envSessionBankroll="UNKNOWN", envScale="UNKNOWN", envPreambleStatus="None", envShuffleStart="UNKNOWN", envPlate="UNKNOWN"; int publicationNumber=0; double offset=0; double actualStart=Double.NaN,actualEnd=Double.NaN; boolean hadPreamble=false; boolean standalonePreamble=false; boolean preambleScreenshot=false; int preambleCards=0; boolean realCasino=false; boolean ultimateV2Replay=false; List<Hand> hands=new ArrayList<>(); }
    static class Hand {
        int number; String startBank="?",endBank="?",refWager="?",wager="?",committed="?",result="?",dealerUp="?";
        double offset=0;
        double researchStart(){ return parseMoney(startBank)-offset; }
        double researchEnd(){ return parseMoney(endBank)-offset; }
        List<String> player=new ArrayList<>(), dealerHidden=new ArrayList<>(), actions=new ArrayList<>(), splitA=new ArrayList<>(),splitB=new ArrayList<>();
        List<String> wagerMindset=new ArrayList<>(), actionMindset=new ArrayList<>(), postHandMindset=new ArrayList<>();
        String splitAResult="", splitBResult="";
        double splitAStake=Double.NaN, splitBStake=Double.NaN;
        boolean split=false,evidenceGap=false,playerBust=false,dealerBust=false,natural=false;
        boolean shuffleBefore=false; int shufflePreviousCards=0; Boolean frozenFollowed=null;
    }

    static class Parser {
        private static final Pattern SESSION=Pattern.compile("(?m)^=+\\s*SESSION\\s+([^\\s=]+)(?:\\s+\\([^\\r\\n=]+\\))?\\s*=+\\s*$");
        private static final Pattern SUMMARY=Pattern.compile("(?m)^HAND\\s+(\\d{2})\\s*\\|\\s*([^|]+?)\\s*\\|\\s*ref\\s+([0-9.]+)\\s+actual\\s+([0-9.]+)\\s*\\|\\s*committed\\s+([0-9.]+)\\s*\\|\\s*([^|]+)\\|\\s*(.+)$");
        static List<Session> parse(String text){
            List<Session> out=new ArrayList<>(); Matcher sm=SESSION.matcher(text); List<Integer> starts=new ArrayList<>(); List<String> ids=new ArrayList<>();
            while(sm.find()){starts.add(sm.start());ids.add(sm.group(1));}
            for(int i=0;i<starts.size();i++){
                int a=starts.get(i),b=(i+1<starts.size()?starts.get(i+1):text.length()); String block=text.substring(a,b);
                Session s=parseSession(ids.get(i),block); if(!s.hands.isEmpty()) out.add(s);
            }
            // v15.10.209: correction addenda can sit after the original SESSION block and
            // therefore outside parseSession(). Reconcile explicit CARD CORRECTION records globally
            // by their stated session ID + hand number; never infer a replacement.
            applyGlobalExplicitCardCorrections(text,out);

            // If no explicit project-ledger SESSION header, parse whole file as one session.
            if(out.isEmpty()){Session s=parseSession("FROM_OUTPUT",text);if(!s.hands.isEmpty())out.add(s);}
            // v15.10.113-UAT: pin the accepted curated Session 1-10 numbering by immutable
            // session ID. Sequential counting alone can shift Session 10 to S9 when an older
            // retained ledger/correction block is absent from a particular output.txt.
            Map<String,Integer> curatedPublicationNumber = new HashMap<>();
            curatedPublicationNumber.put("20260903_004704_CORRECTED",1);
            curatedPublicationNumber.put("20260903_004704",1); // legacy header renders as SESSION ... (CORRECTED)
            curatedPublicationNumber.put("20260903_221223",2);
            curatedPublicationNumber.put("20260905_174453",3);
            curatedPublicationNumber.put("20260906_163019",4);
            curatedPublicationNumber.put("20260906_195138",5);
            curatedPublicationNumber.put("20260907_003015",6);
            curatedPublicationNumber.put("20260907_020601",7);
            curatedPublicationNumber.put("20260908_021956",8);
            curatedPublicationNumber.put("20260910_204102",9);
            curatedPublicationNumber.put("20260912_175237",10);
            int highestKnown=0;
            for(Session s:out){
                if(s.id!=null && s.id.contains("20260903_135610")){s.publicationNumber=0;continue;}
                Integer fixed=curatedPublicationNumber.get(s.id);
                if(fixed!=null){s.publicationNumber=fixed;highestKnown=Math.max(highestKnown,fixed);}
            }
            int nextPub=Math.max(10,highestKnown);
            for(Session s:out){
                if(s.publicationNumber>0 || (s.id!=null && s.id.contains("20260903_135610")))continue;
                s.publicationNumber=++nextPub;
            }
            // v15.10.83-UAT: structured mindset streams can live after the formal session
            // block. Bind them back to the publication session number so Replay shows the
            // operator's recorded wager/action/post-hand narrative at the correct hand.
            attachStructuredMindsetEvents(text,out);
            return out;
        }
        static void attachStructuredMindsetEvents(String text,List<Session> sessions){
            Matcher sec=Pattern.compile("(?s)=+\\s*SESSION\\s+(\\d+)\\s+HUMAN-MINDSET.*?(?==+\\s*END\\s+SESSION\\s+\\1\\s+STRUCTURED\\s+HUMAN-MINDSET\\s+EVENTS\\s*=+|\\z)",Pattern.CASE_INSENSITIVE).matcher(text);
            while(sec.find()){
                int pubNo=Integer.parseInt(sec.group(1)); Session target=null;
                for(Session q:sessions)if(q.publicationNumber==pubNo){target=q;break;}
                if(target==null && sessions.size()==1 && "HYBRID".equalsIgnoreCase(sessions.get(0).architecture)) target=sessions.get(0);
                if(target==null)continue;
                attachMindsetLines(sec.group(),target);
            }
            // v15.10.116: dedicated live-session reports often keep live MINDSET_EVENT
            // records inline with their hands rather than in the later HUMAN-MINDSET wrapper.
            // With one already-identified Hybrid session this direct attachment is unambiguous.
            // AUDIT events are deliberately excluded because they were not known during play.
            if(sessions.size()==1 && "HYBRID".equalsIgnoreCase(sessions.get(0).architecture))
                attachMindsetLines(text,sessions.get(0));
        }
        static void attachMindsetLines(String text,Session target){
            if(target==null)return;
            Map<Integer,Hand> byHand=new HashMap<>();for(Hand h:target.hands)byHand.put(h.number,h);
            Matcher me=Pattern.compile("(?m)^\\s*MINDSET_EVENT\\s*\\|\\s*H(\\d+)\\s*\\|\\s*STAGE\\s+(WAGER|ACTION|POST_HAND)\\s*\\|.*?\\|\\s*TEXT\\s+(.*)$",Pattern.CASE_INSENSITIVE).matcher(text);
            while(me.find()){
                Hand h=byHand.get(Integer.parseInt(me.group(1)));if(h==null)continue;
                String stage=me.group(2).toUpperCase(Locale.ROOT),txt=me.group(3).trim().replace("\\|","|");
                List<String> dest=stage.equals("WAGER")?h.wagerMindset:stage.equals("ACTION")?h.actionMindset:h.postHandMindset;
                if(!dest.contains(txt))dest.add(txt);
            }
        }
        static Session parseSession(String id,String block){
            Session s=new Session();s.id=id;
            s.realCasino = Pattern.compile("(?im)^REAL CASINO SCALE \\||^SOURCE TYPE:\\s*Observed real-money online casino blackjack").matcher(block).find();
            s.hadPreamble = Pattern.compile("(?im)^PREAMBLE\\s*:\\s*YES\\b|^PREAMBLE SOURCE\\s*:").matcher(block).find()
                    || Pattern.compile("(?im)^SESSION\\s+NAME\\s*:\\s*.*PREAMBLE").matcher(block).find();
            s.preambleScreenshot = Pattern.compile("(?im)^PREAMBLE_MODE\\s*:\\s*SCREENSHOT_RECONSTRUCTION\\b|^PREAMBLE_ORIGIN\\s*:\\s*SCREENSHOT_RECONSTRUCTION\\b|^CAPTURE_ORIGIN\\s*:\\s*SCREENSHOT_RECONSTRUCTION\\b").matcher(block).find();
            // v15.10.314: retain captured preamble card distance for dynamic entry-position display.
            Matcher preCards=Pattern.compile("(?im)^PREAMBLE\\s*:\s*\\d+\s*hands\s*/\s*cards\s*:\s*(\\d+)").matcher(block);
            if(preCards.find()) s.preambleCards=Integer.parseInt(preCards.group(1));
            if(s.preambleCards<=0){ Matcher pm=Pattern.compile("(?im)^PREAMBLE SOURCE\\s*:.*?(?:cards\s*[:=]?\s*|/\s*cards\s*:\s*)(\\d+)").matcher(block); if(pm.find())s.preambleCards=Integer.parseInt(pm.group(1)); }
            Matcher nameMatcher=Pattern.compile("(?im)^SESSION\\s+NAME\\s*:\\s*(.+)$").matcher(block);
            if(nameMatcher.find()) s.sessionName=nameMatcher.group(1).trim();
            else {
                Matcher ledgerName=Pattern.compile("(?im)^LEDGER_ENTRY\\s*\\|.*?\\|\\s*name\\s+([^|]+)\\|").matcher(block);
                if(ledgerName.find()) s.sessionName=ledgerName.group(1).trim();
            }
            Matcher modeMatcher=Pattern.compile("(?im)^LEDGER_ENTRY\\s*\\|.*?\\|\\s*mode\\s+([A-Z_-]+)\\s*\\|").matcher(block);
            if(modeMatcher.find()) s.architecture=modeMatcher.group(1).trim().toUpperCase();
            else {
                Matcher explicitMode=Pattern.compile("(?im)^MODE\\s*:\\s*(FROZEN|HYBRID)\\b").matcher(block);
                if(explicitMode.find()) s.architecture=explicitMode.group(1).trim().toUpperCase();
            }
            Matcher om = Pattern.compile("(?i)research-equivalent bankroll\s*=\s*platform balance\s*-\s*£?([0-9.]+)").matcher(block);
            if(om.find()) s.offset=parseMoney(om.group(1));
            else { Matcher bm=Pattern.compile("(?i)PLATFORM BALANCE BASIS:\s*Start £?([0-9.]+).*?£?([0-9.]+)\s*=\s*research-equivalent £?0").matcher(block); if(bm.find()) s.offset=parseMoney(bm.group(2)); }

            // A documented pre-Hand-1 balance correction changes the session floor as well.
            // Example: actual pre-Hand-1 platform £2545 means the research floor is £2445,
            // because every observed live session independently starts at research £100.
            // This is deliberately evidence-driven: no correction is applied unless the
            // session block explicitly records the corrected pre-Hand-1 platform balance.
            Matcher startCorrection = Pattern.compile(
                "(?i)actual\\s+pre-Hand-1\\s+platform\\s+balance\\s+was\\s*[^0-9]*([0-9]+(?:\\.[0-9]+)?)"
            ).matcher(block);
            if(startCorrection.find()) {
                double correctedPlatformStart = parseMoney(startCorrection.group(1));
                if(correctedPlatformStart >= 100.0) s.offset = correctedPlatformStart - 100.0;
            }
            LinkedHashMap<Integer,Hand> hands=new LinkedHashMap<>();
            Matcher m=SUMMARY.matcher(block);
            while(m.find()){
                int n=Integer.parseInt(m.group(1)); if(hands.containsKey(n)) continue;
                Hand h=new Hand();h.number=n; h.offset=s.offset;
                String[] bank=m.group(2).trim().split("->"); if(bank.length==2){h.startBank=bank[0].trim();h.endBank=bank[1].trim();}
                h.refWager=m.group(3);h.wager=m.group(4);h.committed=m.group(5); String tail=m.group(7).trim(); parseTail(h,tail); hands.put(n,h);
            }
            // action provenance from the detailed per-hand audit chunks
            for(Hand h:hands.values()){
                Pattern hp=Pattern.compile("(?s)=+\\s*HAND\\s+"+h.number+"\\s*/\\s*30\\s*=+(.*?)(?=\\r?\\nHAND\\s+"+h.number+"\\s*\\||=+\\s*HAND\\s+"+(h.number+1)+"\\s*/\\s*30\\s*=+|\\z)");
                Matcher hm=hp.matcher(block); if(hm.find()){
                    String chunk=hm.group(1); Matcher am=Pattern.compile("actual\\s+(HIT|STAND|DOUBLE|SPLIT)",Pattern.CASE_INSENSITIVE).matcher(chunk);
                    while(am.find()){String a=am.group(1).toUpperCase(); if(h.actions.isEmpty()||!h.actions.get(h.actions.size()-1).equals(a)) h.actions.add(a);}
                    String lc=chunk.toLowerCase();
                    Matcher sh=Pattern.compile("(?i)SHUFFLE RECORDED before hand\\s+"+h.number+"\\s*\\|\\s*previous segment cards recorded:\s*(\\d+)").matcher(chunk); if(sh.find()){h.shuffleBefore=true;h.shufflePreviousCards=Integer.parseInt(sh.group(1));}
                    Matcher cm=Pattern.compile("(?i)\\bfrozen\\s+(HIT|STAND|DOUBLE|SPLIT)\\s*\\|\\s*actual\\s+(HIT|STAND|DOUBLE|SPLIT)").matcher(chunk); boolean saw=false,ok=true; while(cm.find()){saw=true;if(!cm.group(1).equalsIgnoreCase(cm.group(2)))ok=false;} if(saw)h.frozenFollowed=ok;
                    if(isTwoCardNatural(h.player)) { h.natural=true; if(h.actions.isEmpty()||!"NATURAL".equalsIgnoreCase(h.actions.get(0))) h.actions.add(0,"NATURAL"); }
                    if(Pattern.compile("(?i)MAIN\\s+BUST|PLAYER\\s+BUST").matcher(chunk).find()) h.playerBust=true;
                    if(Pattern.compile("(?i)DEALER[^\\n]*(BUST|=\\s*2[2-9]|=\\s*3[0-9])").matcher(chunk).find()) h.dealerBust=true;

                    // Later session summaries can retain only the original pair even when the hand was split.
                    // Reconstruct graphical child hands only from explicit Split A / Split B lines in the audit chunk.
                    if(Pattern.compile("(?i)actual\s+SPLIT").matcher(chunk).find()) {
                        h.split = true;
                        Matcher sa = Pattern.compile("(?im)^\\s*Split A(?:\\s+after\\s+DOUBLE\\s*:|\\s+)\\s*([^\\n]+?)\\s*=\\s*(?:soft\\s+)?\\d+(?:\\s*\\|.*)?$").matcher(chunk);
                        Matcher sb = Pattern.compile("(?im)^\\s*Split B(?:\\s+after\\s+DOUBLE\\s*:|\\s+)\\s*([^\\n]+?)\\s*=\\s*(?:soft\\s+)?\\d+(?:\\s*\\|.*)?$").matcher(chunk);
                        List<String> bestA=new ArrayList<>(), bestB=new ArrayList<>();
                        while(sa.find()){List<String> q=cards(sa.group(1));if(q.size()>bestA.size())bestA=q;}
                        while(sb.find()){List<String> q=cards(sb.group(1));if(q.size()>bestB.size())bestB=q;}
                        if(!bestA.isEmpty())h.splitA=bestA; if(!bestB.isEmpty())h.splitB=bestB;
                        if(h.splitA.isEmpty() && h.player.size()>=1) h.splitA.add(h.player.get(0));
                        if(h.splitB.isEmpty() && h.player.size()>=2) h.splitB.add(h.player.get(1));

                        Matcher sao = Pattern.compile("(?im)^\\s*Split A[^\\n]*(?:\\bWIN\\b|\\bLOSS\\b|\\bPUSH\\b)").matcher(chunk);
                        Matcher sbo = Pattern.compile("(?im)^\\s*Split B[^\\n]*(?:\\bWIN\\b|\\bLOSS\\b|\\bPUSH\\b)").matcher(chunk);
                        if(sao.find()) h.splitAResult = explicitOutcome(sao.group());
                        if(sbo.find()) h.splitBResult = explicitOutcome(sbo.group());

                        // v15.10.203: the live audit can record a split child as "BUST at N"
                        // without repeating its final card list on that line. Recover only a
                        // uniquely supported captured PLAYER post-deal card whose addition makes
                        // the rendered branch equal the explicitly recorded bust total. This fixes
                        // Session 12 H2 Split B (3S,10H + captured JD = 23) without inventing cards.
                        recoverMissingSplitBustCard(h, chunk, "A");
                        recoverMissingSplitBustCard(h, chunk, "B");

                        Matcher scr=Pattern.compile("(?im)^\\s*SPLIT_CHILD_RESULT\\s*\\|\\s*A total \\d+ stake ([0-9.]+) result ([WLP])\\s*\\|\\s*B total \\d+ stake ([0-9.]+) result ([WLP])").matcher(chunk);
                        if(scr.find()){
                            h.splitAStake=parseMoney(scr.group(1)); h.splitAResult=scr.group(2).toUpperCase();
                            h.splitBStake=parseMoney(scr.group(3)); h.splitBResult=scr.group(4).toUpperCase();
                        }
                    }
                }
            }
            // v15.10.209: Apply only explicit retained CARD CORRECTION evidence to the graphical
            // hand tokens before rendering. This is generic provenance reconciliation, not inference:
            // e.g. Session 10 H22 records "raw chronology 6? / operator review 6U -> 6C".
            // A correction is used only when the same hand still contains the stated unresolved token.
            applyExplicitCardCorrections(block, hands);

            // Structured human-mindset events are replay evidence. Preserve the recorded text verbatim
            // and place it only at its documented temporal stage; never infer a missing note.
            Matcher me=Pattern.compile("(?m)^MINDSET_EVENT\\s*\\|\\s*H(\\d+)\\s*\\|\\s*STAGE\\s+(WAGER|ACTION|POST_HAND)\\s*\\|.*?\\|\\s*TEXT\\s+(.*)$",Pattern.CASE_INSENSITIVE).matcher(block);
            while(me.find()){
                int hn=Integer.parseInt(me.group(1)); Hand mh=hands.get(hn); if(mh==null)continue;
                String stage=me.group(2).toUpperCase(Locale.ROOT), txt=me.group(3).trim().replace("\\|","|");
                if(stage.equals("WAGER"))mh.wagerMindset.add(txt); else if(stage.equals("ACTION"))mh.actionMindset.add(txt); else mh.postHandMindset.add(txt);
            }
            s.hands.addAll(hands.values());
            if(!s.hands.isEmpty()){
                Hand first=s.hands.get(0),last=s.hands.get(s.hands.size()-1); s.summary="Recorded £"+money(first.researchStart())+" → £"+money(last.researchEnd());
            }
            return s;
        }
        static void parseTail(Hand h,String tail){
            String[] f=tail.split("\\s*\\|\\s*");
            for(String x:f){
                if(x.startsWith("P original ")){
                    h.split=true; String z=x.substring("P original ".length()); int ar=z.indexOf("-> A ");
                    if(ar>=0){h.player=cards(z.substring(0,ar));String rest=z.substring(ar+5);int semi=rest.indexOf("; B ");if(semi>=0){String aRaw=rest.substring(0,semi),bRaw=rest.substring(semi+4);h.splitA=cards(stripParen(aRaw));h.splitB=cards(stripParen(bRaw));h.splitAResult=splitResult(aRaw);h.splitBResult=splitResult(bRaw);}}
                } else if(x.startsWith("P ")) h.player=cards(x.substring(2));
                else if(x.startsWith("D-up ")) h.dealerUp=x.substring(5).trim();
                else if(x.startsWith("D-hidden/draw ")) h.dealerHidden=cards(x.substring(14));
                else if(x.startsWith("D ")) { // special split dealer full hand: first shown as upcard in historical summary
                    String z=x.substring(2); int eq=z.indexOf(" = "); if(eq>=0) z=z.substring(0,eq); List<String>d=cards(z); if(!d.isEmpty()){h.dealerUp=d.get(0);h.dealerHidden=new ArrayList<>(d.subList(1,d.size()));}
                } else if(x.matches("(?i)(W|L|P|SPLIT.*|dealer.*)")) h.result=x.trim();
            }
            if(h.result.equals("?")) h.result=f[f.length-1].trim();
            String up=tail.toUpperCase(); if(up.contains("UNKNOWN")||up.contains("INCOMPLETE")||up.contains("[?]"))h.evidenceGap=true;
            // Hand 28 in Session 1 is known in the research record to have incomplete retained split-B card evidence.
            if(h.number==28 && h.split==false && h.committed.equals("30.00")) h.evidenceGap=true;
        }
        static void applyGlobalExplicitCardCorrections(String text,List<Session> sessions){
            Matcher sec=Pattern.compile("(?im)^=+\\s*SESSION EVIDENCE CORRECTION\\s*\\|\\s*SESSION\\s+([^\\s=]+)\\s*=+\\s*$").matcher(text);
            while(sec.find()){
                String sid=sec.group(1); int end=text.indexOf("================ SESSION ",sec.end()); if(end<0)end=text.length();
                String block=text.substring(sec.start(),end); Session target=null; for(Session s:sessions)if(s.id.equals(sid)){target=s;break;} if(target==null)continue;
                Map<Integer,Hand> hm=new LinkedHashMap<>();for(Hand h:target.hands)hm.put(h.number,h);applyExplicitCardCorrections(block,hm);
            }
        }

        static void applyExplicitCardCorrections(String block, Map<Integer,Hand> hands){
            Matcher cm=Pattern.compile("(?im)^CARD CORRECTION:\\s*#\\d+\\s*\\|\\s*H(\\d+)\\s*\\|[^\\n]*?raw chronology\\s+([^\\s/]+)(?:\\s*/\\s*operator review\\s+[^\\s]+)?\\s*->\\s*([^\\s|]+)").matcher(block);
            while(cm.find()){
                Hand h=hands.get(Integer.parseInt(cm.group(1))); if(h==null)continue;
                String raw=cm.group(2).trim(), corrected=cm.group(3).trim();
                CCard ccorr=cc(corrected); if(ccorr==null)continue; String display=ccorr.toString();
                replaceCardToken(h.player,raw,display); replaceCardToken(h.splitA,raw,display); replaceCardToken(h.splitB,raw,display);
                if(cardKey(h.dealerUp).equals(cardKey(raw)))h.dealerUp=display; replaceCardToken(h.dealerHidden,raw,display);
            }
        }
        static void replaceCardToken(List<String> xs,String raw,String corrected){
            if(xs==null)return; String rk=cardKey(raw);
            for(int i=0;i<xs.size();i++)if(cardKey(xs.get(i)).equals(rk)){xs.set(i,corrected);return;}
        }

        static void recoverMissingSplitBustCard(Hand h,String chunk,String branch){
            Matcher bm=Pattern.compile("(?im)^\\s*SPLIT "+branch+" BUST at (\\d+)\\s*$").matcher(chunk);
            if(!bm.find()) return;
            int target=Integer.parseInt(bm.group(1));
            List<String> hand=branch.equals("A")?h.splitA:h.splitB;
            if(hand.isEmpty() || cardTotal(hand)==target) return;
            List<String> stream=new ArrayList<>();
            Matcher pm=Pattern.compile("(?im)^#\\d+\\s*\\|\\s*H\\d+\\s*\\|\\s*PLAYER post-deal card\\s*\\|\\s*([0-9AJQK]+[SHDC])\\s*$").matcher(chunk);
            while(pm.find()){List<String> one=cards(pm.group(1)); if(!one.isEmpty()) stream.add(one.get(0));}
            List<String> represented=new ArrayList<>();
            if(h.splitA.size()>1) represented.addAll(h.splitA.subList(1,h.splitA.size()));
            if(h.splitB.size()>1) represented.addAll(h.splitB.subList(1,h.splitB.size()));
            List<String> remaining=new ArrayList<>(stream);
            for(String c:represented){ for(int i=0;i<remaining.size();i++) if(cardKey(remaining.get(i)).equals(cardKey(c))){remaining.remove(i);break;} }
            List<String> candidates=new ArrayList<>();
            for(String c:remaining){List<String> q=new ArrayList<>(hand);q.add(c);if(cardTotal(q)==target)candidates.add(c);}
            if(candidates.size()==1) {
                // v15.10.204: PLAYER post-deal stream tokens use ASCII suit suffixes
                // (e.g. JD). Convert the recovered, source-supported card to the same
                // Unicode-suit representation used by the Replay renderer. Rank/value
                // and evidence are unchanged; this prevents a known JD being drawn as
                // an unresolved-suit card (J / ??).
                CCard recovered=cc(candidates.get(0));
                hand.add(recovered==null ? candidates.get(0) : recovered.toString());
            }
        }
        static String cardKey(String c){return c==null?"":c.replace("♠","S").replace("♥","H").replace("♦","D").replace("♣","C").trim().toUpperCase(Locale.ROOT);}

        static String explicitOutcome(String s){
            String u=s.toUpperCase();
            if(u.contains("WIN")) return "W";
            if(u.contains("LOSS")) return "L";
            if(u.contains("PUSH")) return "P";
            return "";
        }
        static String splitResult(String s){Matcher m=Pattern.compile("\\((?:[^,]*,){2}\\s*([WLP])\\s*\\)",Pattern.CASE_INSENSITIVE).matcher(s);return m.find()?m.group(1).toUpperCase():"";}
        static String stripParen(String s){int p=s.indexOf(" (");return p>=0?s.substring(0,p):s;}
        static List<String> cards(String s){
            List<String> r=new ArrayList<>(); for(String q:s.split(",")){q=q.trim(); if(q.isEmpty())continue; int p=q.indexOf(" (");if(p>0)q=q.substring(0,p).trim(); if(q.startsWith("+UNKNOWN"))q="?"; r.add(q);} return r;
        }
    }


    static void attachStandalonePreambleDetailedEvidence(String et, Session q){
        if(et==null||q==null)return;
        for(Hand h:q.hands){
            Pattern hp=Pattern.compile("(?s)=+\\s*PREAMBLE HAND\\s+"+h.number+"\\s*=+(.*?)(?==+\\s*PREAMBLE HAND\\s+"+(h.number+1)+"\\s*=+|\\z)",Pattern.CASE_INSENSITIVE);
            Matcher hm=hp.matcher(et); if(!hm.find())continue;
            String chunk=hm.group(1);

            Matcher ac=Pattern.compile("(?im)^\\s*MAIN[^\\n]*?\\|\\s*(?:personal\\s+)?actual\\s+(HIT|STAND|DOUBLE|SPLIT)\\s*$").matcher(chunk);
            while(ac.find()){String a=ac.group(1).toUpperCase(Locale.ROOT);if(!h.actions.contains(a))h.actions.add(a);}

            Matcher sh=Pattern.compile("(?i)SHUFFLE RECORDED before hand\\s+"+h.number+"\\s*\\|\\s*previous segment cards recorded:\\s*(\\d+)").matcher(chunk);
            if(sh.find()){h.shuffleBefore=true;h.shufflePreviousCards=Integer.parseInt(sh.group(1));}

            // Observed split authority: only an explicit recorded "actual SPLIT" may create
            // observed Split A/B. A Frozen-policy SPLIT recommendation alone is insufficient.
            if(Pattern.compile("(?i)\\bactual\\s+SPLIT\\b").matcher(chunk).find()){
                h.split=true;
                Matcher sa=Pattern.compile("(?im)^\\s*Split A\\s+([^\\n]+?)\\s*=\\s*(?:soft\\s+)?\\d+(?:\\s*\\|.*)?$").matcher(chunk);
                Matcher sb=Pattern.compile("(?im)^\\s*Split B\\s+([^\\n]+?)\\s*=\\s*(?:soft\\s+)?\\d+(?:\\s*\\|.*)?$").matcher(chunk);
                List<String> bestA=new ArrayList<>(),bestB=new ArrayList<>();
                while(sa.find()){List<String>x=Parser.cards(sa.group(1));if(x.size()>bestA.size())bestA=x;}
                while(sb.find()){List<String>x=Parser.cards(sb.group(1));if(x.size()>bestB.size())bestB=x;}
                if(!bestA.isEmpty())h.splitA=bestA;if(!bestB.isEmpty())h.splitB=bestB;
                if(h.splitA.isEmpty()&&h.player.size()>=1)h.splitA.add(h.player.get(0));
                if(h.splitB.isEmpty()&&h.player.size()>=2)h.splitB.add(h.player.get(1));

                Matcher scr=Pattern.compile("(?im)^\\s*SPLIT_CHILD_RESULT\\s*\\|\\s*A total \\d+ stake ([0-9.]+) result ([WLP])\\s*\\|\\s*B total \\d+ stake ([0-9.]+) result ([WLP])").matcher(chunk);
                if(scr.find()){
                    h.splitAStake=parseMoney(scr.group(1));h.splitAResult=scr.group(2).toUpperCase(Locale.ROOT);
                    h.splitBStake=parseMoney(scr.group(3));h.splitBResult=scr.group(4).toUpperCase(Locale.ROOT);
                }
            }
        }
    }

    static void sessionEnvironmentAuthorityRegressionGate(){
        String fixture="================ SESSION 20260903_004704_CORRECTED ================\nSOURCE: Paddy Power\n================ END SESSION 20260903_004704_CORRECTED ================\n"+
            "================ SESSION 20260903_135610_CORRECTED_247_DEAL_ORDER ================\nSOURCE: 247blackjack.com\nSOURCE TYPE: Online play-money blackjack, configured 6-deck game\n================ END SESSION 20260903_135610_CORRECTED_247_DEAL_ORDER ================";
        String scoped=sessionEvidenceBlock(fixture,"20260903_135610_CORRECTED_247_DEAL_ORDER");
        Session q=new Session(); q.id="20260903_135610_CORRECTED_247_DEAL_ORDER"; populateEnvironmentEvidence(q,scoped);
        if(!"247blackjack.com".equalsIgnoreCase(q.platform))throw new IllegalStateException("I10 environment authority regression: cross-session platform leakage");
        if(!q.envCapture.toLowerCase(Locale.ROOT).contains("online play-money"))throw new IllegalStateException("I10 environment authority regression: source type not session-scoped");
        System.out.println("I10 session environment authority: PASS (cumulative output scoped to exact session; 20260903_135610 remains 247blackjack.com)");
    }

    static void standalonePreambleObservedSplitRegressionGate(){
        Session q=new Session();Hand h=new Hand();h.number=8;h.player.add("6♥");h.player.add("6♥");q.hands.add(h);
        String evidence="========== PREAMBLE HAND 8 ==========\n"+
                "MAIN 6♥,6♥ = 12 | FROZEN POLICY: SPLIT [TEXTBOOK / FROZEN-ALIGNED]\n"+
                "  MAIN | 6♥,6♥ | frozen SPLIT | actual SPLIT\n"+
                "Split A 6♥,10♥ = 16\nSplit B 6♥,9♠ = 15\n"+
                "SPLIT_CHILD_RESULT | A total 16 stake 15.00 result L | B total 15 stake 15.00 result L\n";
        attachStandalonePreambleDetailedEvidence(evidence,q);
        if(!h.split||h.splitA.size()!=2||h.splitB.size()!=2||
           !"10♥".equals(h.splitA.get(1))||!"9♠".equals(h.splitB.get(1))||
           !"L".equals(h.splitAResult)||!"L".equals(h.splitBResult))
            throw new AssertionError("standalone preamble observed split reconstruction failed split="+h.split+" A="+h.splitA+" B="+h.splitB+" results="+h.splitAResult+"/"+h.splitBResult);
        Session no=new Session();Hand n=new Hand();n.number=8;n.player.add("6♥");n.player.add("6♥");no.hands.add(n);
        String policyOnly="========== PREAMBLE HAND 8 ==========\nMAIN 6♥,6♥ = 12 | FROZEN POLICY: SPLIT [TEXTBOOK]\n";
        attachStandalonePreambleDetailedEvidence(policyOnly,no);
        if(n.split)throw new AssertionError("Frozen policy alone must not create observed preamble split");
        try{
            Path retained=Paths.get("../CLI/session_evidence/blackjack_preamble_20260908_020157.txt");
            if(Files.isRegularFile(retained)){
                String et=Files.readString(retained,StandardCharsets.UTF_8);
                Session real=Parser.parseSession("20260908_020157",et);
                attachStandalonePreambleDetailedEvidence(et,real);
                Hand h8=null;for(Hand x:real.hands)if(x.number==8){h8=x;break;}
                if(h8==null||!h8.split||h8.splitA.size()<2||h8.splitB.size()<2||
                   !"10H".equals(Parser.cardKey(h8.splitA.get(1)))||!"9S".equals(Parser.cardKey(h8.splitB.get(1))))
                    throw new AssertionError("retained preamble 20260908_020157 H8 observed split no longer reconstructs from authority");
            }
        }catch(IOException ioe){throw new AssertionError("standalone preamble retained-evidence split gate failed",ioe);}
        CompareHand rendered=new CompareHand();rendered.player=new ArrayList<>(h.player);
        List<CCard> fixture=new ArrayList<>();String[] toks={"6♥","4♦","6♥","3♦","10♥","9♠","2♣","9♥"};String[] roles={"PLAYER card 1","DEALER hole card","PLAYER card 2","DEALER upcard","PLAYER post-deal card","PLAYER post-deal card","DEALER draw 1","DEALER draw 2"};
        for(int i=0;i<toks.length;i++){String k=Parser.cardKey(toks[i]);String rank=k.substring(0,k.length()-1),suit=k.substring(k.length()-1);CCard c=new CCard(rank,suit,0);c.seq=43+i;c.role=roles[i];fixture.add(c);}
        populateObservedSplitCompareHand(rendered,h,fixture);
        if(rendered.branches.size()!=2||rendered.branchSeq.size()!=2||rendered.branchSeq.get(0).size()!=2||rendered.branchSeq.get(1).size()!=2||rendered.branchSeq.get(0).get(0)!=43||rendered.branchSeq.get(0).get(1)!=47||rendered.branchSeq.get(1).get(0)!=45||rendered.branchSeq.get(1).get(1)!=48)
            throw new AssertionError("observed split renderer transport collapsed or mis-badged children: branches="+rendered.branches+" seq="+rendered.branchSeq);
        System.out.println("I9 standalone-preamble observed split authority: PASS (source -> child model -> renderer transport; explicit actual SPLIT required; retained H8 fixture reconciled)");
    }

    static void historicalPhysicalHandBoundaryRegressionGate(){
        try{
            CObserved o=new CObserved();o.starts.add(1);List<CCard> seg=new ArrayList<>();o.segs.add(seg);
            for(int seq=1;seq<=11;seq++){CCard c=new CCard("2","♣",2);c.seq=seq;c.observedHand=seq<=6?1:2;seg.add(c);}
            CShoe sh=new CShoe(o);sh.before(1);for(int i=0;i<4;i++)sh.draw(); // counterfactual H1 terminates early at #4
            sh.before(2);CCard first=sh.draw();
            if(first.seq!=7)throw new AssertionError("physical hand-boundary firewall expected H2 at #7, got #"+first.seq);
            System.out.println("I8 physical hand-boundary firewall: PASS (post-terminal provenance remains consumed; next hand starts at recorded boundary)");
        }catch(Exception e){throw new AssertionError("physical hand-boundary firewall failed",e);}
    }

    static void historicalActionBoundaryRegressionGate(){
        try{
            Path p=Paths.get("../CLI/output.txt");
            if(!Files.isRegularFile(p)) throw new AssertionError("historical action audit source missing: "+p);
            List<Session> ss=Parser.parse(Files.readString(p,StandardCharsets.UTF_8));
            int checked=0; Hand s3h30=null,s9h30=null;
            // v15.10.306: this is a frozen HISTORICAL regression boundary, not a
            // count of every session currently appended to output.txt. The accepted pre-S15 historical corpus (342 hands) includes the retained
            // non-publication legacy capture plus Sessions 1-14. New formal sessions
            // must remain visible to Replay/publication without silently expanding I7.
            for(Session q:ss){
                if(q.publicationNumber>14) continue;
                for(Hand h:q.hands){ checked++; if(q.publicationNumber==3&&h.number==30)s3h30=h; if(q.publicationNumber==9&&h.number==30)s9h30=h; }
            }
            if(checked!=342) throw new AssertionError("historical action audit expected 342 frozen pre-S15 historical hands, got "+checked);
            if(s3h30==null||!s3h30.actions.equals(List.of("NATURAL"))) throw new AssertionError("S3 H30 action boundary regression: "+(s3h30==null?"missing":s3h30.actions));
            if(s9h30==null||!s9h30.actions.equals(List.of("STAND"))) throw new AssertionError("S9 H30 action boundary regression: "+(s9h30==null?"missing":s9h30.actions));
            System.out.println("I7 historical action-boundary audit: PASS (342 hands; S3 H30 NATURAL; S9 H30 STAND)");
        }catch(Exception e){throw new AssertionError("historical action-boundary audit failed",e);}
    }

    public static void main(String[] args){
        replaySemanticRegressionGate();
        if(args!=null&&args.length>0&&"--self-test".equalsIgnoreCase(args[0])){historicalActionBoundaryRegressionGate();historicalPhysicalHandBoundaryRegressionGate();standalonePreambleObservedSplitRegressionGate();sessionEnvironmentAuthorityRegressionGate();System.out.println("Replay semantic regression gate: PASS (I1-I10)");return;}
        SwingUtilities.invokeLater(()->{ try{UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());}catch(Exception ignored){} new BlackjackSessionReplay().setVisible(true); });
    }
}


// -----------------------------------------------------------------------------
// LIVE GUI v15.10.42 NAVIGATION + STORY-EXPLANATION WRAPPER
// -----------------------------------------------------------------------------
/**
 * v15.10.42 retains the v15.10.40 navigation wrapper; Replay Stats Corner stories now add wager-assumption audits.
 * The inherited live/backend behaviour is unchanged. This class only adds
 * a top-right Stats Corner shortcut and updates visible GUI version text.
 */

/**
 * Release entry point for the frozen Live Graphical Companion.
 * Delegates to the accepted UAT-proven implementation without changing play behaviour.
 */
class BlackjackLiveGui extends BlackjackLiveGuiUAT {
    // Pre-Phase-6 housekeeping: RUN_LIVE_GUI.bat is a standalone javaw entry
    // point, so guard it across JVMs. Replay-embedded Live remains protected by
    // its existing in-JVM window check and does not use this main entry point.
    private static java.nio.channels.FileChannel directInstanceChannel;
    private static java.nio.channels.FileLock directInstanceLock;

    private static boolean acquireDirectInstanceLock() {
        try {
            Path lockPath = Paths.get(System.getProperty("java.io.tmpdir"), "DBJ_V9_2_161_LIVE_GUI.lock");
            directInstanceChannel = java.nio.channels.FileChannel.open(lockPath,
                    StandardOpenOption.CREATE, StandardOpenOption.WRITE);
            directInstanceLock = directInstanceChannel.tryLock();
            if (directInstanceLock == null) {
                directInstanceChannel.close(); directInstanceChannel = null;
                return false;
            }
            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                try { if (directInstanceLock != null && directInstanceLock.isValid()) directInstanceLock.release(); } catch (Exception ignored) {}
                try { if (directInstanceChannel != null && directInstanceChannel.isOpen()) directInstanceChannel.close(); } catch (Exception ignored) {}
            }, "dbj-live-instance-lock-release"));
            return true;
        } catch (java.nio.channels.OverlappingFileLockException ex) {
            return false;
        } catch (Exception ex) {
            // Fail closed: do not risk creating another detached DBJ javaw.
            return false;
        }
    }

    public static void main(String[] args) {
        if (!acquireDirectInstanceLock()) {
            JOptionPane.showMessageDialog(null,
                    "The Deterministic Blackjack Live application is already running.",
                    "Live Play already open", JOptionPane.INFORMATION_MESSAGE);
            System.exit(0);
            return;
        }
        SwingUtilities.invokeLater(() -> new BlackjackLiveGui().setVisible(true));
    }
}

class BlackjackLiveGuiUAT extends BlackjackLiveGuiOld {
    private static final String UI_VERSION = "15.10.239-UAT"; // historical-restoration UAT presentation/provenance repair
    private final JButton statsCornerButton = new JButton("STATS CORNER");
    private final BlackjackSessionReplay.ControllerIdentityGlyph liveHumanIdentity = new BlackjackSessionReplay.ControllerIdentityGlyph("HYBRID");
    private final JPanel percentileWagerPanel = new JPanel(new BorderLayout(0, 1));
    private final JPanel scaledWagerPanel = new JPanel(new BorderLayout(0, 1));
    private final JLabel scaledWagerValues = new JLabel("£0.30  •  £0.40  •  £0.60", SwingConstants.CENTER);
    private final JLabel scaledWagerNote = new JLabel("£2.00 bankroll • Paddy Power CashOut", SwingConstants.CENTER);
    private final JLabel scaledWagerEquivalent = new JLabel("Equivalent to £15 • £20 • £30", SwingConstants.CENTER);
    private final JPanel liveCardRatePanel = new JPanel();
    private final JLabel liveThreeRate = new JLabel("—", SwingConstants.CENTER);
    private final JLabel liveFourRate = new JLabel("—", SwingConstants.CENTER);
    private final JLabel liveThreeCount = new JLabel("0 resolved", SwingConstants.CENTER);
    private final JLabel liveFourCount = new JLabel("0 resolved", SwingConstants.CENTER);
    private int live3Resolved=0, live3Wins=0, live4Resolved=0, live4Wins=0, lastRateHand=-1;
    private int pendingPlayerCardCount=0, pendingHand=-1;
    // v15.10.230-UAT: presentation-only snapshot captured before result submission can
    // reset inherited live-card state. Used solely by the PREVIOUS HAND panel.
    private final java.util.List<String> completedHandPresentationSnapshot=new ArrayList<>();
    private int lastExplicitJourneyHand=-1; // v15.10.175: split-result journey commit guard
    private boolean paddyPowerLive=false;
    // v15.10.191-UAT: presentation-only resolved child outcomes for the active split.
    private String liveSplitAOutcome="", liveSplitBOutcome="";
    // v15.10.199-UAT: presentation-only snapshots of the two active split branches.
    // The inherited live table already owns these cards; retain the same rendered
    // branch state so RESULT/COMPLETE never reconstructs a fictitious single hand.
    private final java.util.List<String> liveSplitACards=new ArrayList<>();
    private final java.util.List<String> liveSplitBCards=new ArrayList<>();
    private String liveSplitADoubleCard="";
    private String liveSplitBDoubleCard="";
    // v15.10.200-UAT: retained split-branch cards are scoped to the hand that created them.
    // Prevents a completed split in Hand N from being mistaken for a split in ordinary Hand N+1.
    private String liveSplitHand="";
    private final JPanel visibleFrequencyPanel = new JPanel(new BorderLayout(10,0));
    private final JLabel visibleFrequencyLegend = new JLabel("↑ High     ↓ Low     — Neutral", SwingConstants.CENTER);
    private final JLabel visibleFrequencyCounts = new JLabel("↑ HIGH 0     ↓ LOW 0     — NEUTRAL 0", SwingConstants.CENTER);
    // v15.10.193-UAT: compact concentration controls. Presentation choices only.
    private final JPanel concentrationControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0));
    private final JCheckBox handAnalysisCheck = new JCheckBox("Hand Analysis", true);
    private final JCheckBox reviewNotesCheck = new JCheckBox("Optional Notes", true);
    private final JCheckBox controllerEventsCheck = new JCheckBox("Controller Events", true);
    // v15.10.194-UAT: checkbox selections are pending until the next hand boundary.
    private boolean activeHandAnalysis=true, activeReviewNotes=true, activeControllerEvents=true;
    private String concentrationLatchedHand="";
    private String lastAutoSuppressedPrompt="";
    // v15.10.203-UAT: live presentation mirrors the validated AI V2 observation
    // vocabulary without changing gameplay, wager, action, settlement or evidence authority.
    private String liveV2EvidenceState="INSUFFICIENT";
    private String liveV2Watch="NONE";
    private String liveV2Confidence="TOO EARLY";
    private String liveV2ControllerState="OBSERVING";
    private final JPanel liveV2ObservationPanel=new JPanel(new GridBagLayout());
    // v15.10.233-UAT: compact top controller lifecycle rail. Presentation only.
    // Events are parsed from authoritative backend lifecycle lines; no controller state is created here.
    private final JPanel controllerLifecycleRail=new JPanel(new FlowLayout(FlowLayout.RIGHT,6,0));
    private final java.util.List<JComponent> controllerLifecycleTiles=new ArrayList<>();
    private boolean formalLiveHandStarted=false;
    private int lifecycleRailHand=-1;
    // v15.10.237-UAT: last genuine non-zero V2 shadow exposure; presentation only.
    private double lastNonZeroV2Exposure=0.0;

    @Override void startBackend() {
        // v15.10.302: when Live is opened inside Replay, java.class.path contains
        // only the Replay JAR. Add the sibling CLI directory explicitly so the
        // authoritative V9.2.83 backend remains launchable without a second GUI JVM.
        try {
            Path guiDir = appDir();
            Path cliDir = dataDir();
            String sep = System.getProperty("path.separator");
            String cp = absoluteClassPath(guiDir);
            String cli = cliDir.toAbsolutePath().normalize().toString();
            if (!Arrays.asList(cp.split(java.util.regex.Pattern.quote(sep))).contains(cli)) cp += sep + cli;

            ProcessBuilder pb = new ProcessBuilder(javaExe(), "-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8", "-cp", cp,
                    "LiveBlackjackCompanion_PersonalOrFrozen_OptionalHistory_Ledger");
            pb.directory(cliDir.toFile());
            pb.redirectErrorStream(true);
            process = pb.start();
            stdin = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
            running = true;
            readerThread = new Thread(this::readBackend, "blackjack-cli-reader");
            readerThread.setDaemon(true);
            readerThread.start();
        } catch (Exception ex) {
            appendLocal("\n[GUI] Backend start failed: " + ex + "\n");
            promptLabel.setText("Backend failed to start — run from the supplied package/JAR.");
        }
    }

    public BlackjackLiveGuiUAT() {
        super();
        // Release lifecycle hardening: the detached javaw launcher has no console,
        // so closing the Live GUI must explicitly terminate its JVM as well.
        setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
        addWindowListener(new WindowAdapter() {
            @Override public void windowClosing(WindowEvent e) {
                // v15.10.138: close the authoritative backend process before the
                // Live GUI JVM exits.  Otherwise the child java.exe can survive
                // the visible window and keep the release folder locked on Windows.
                running = false;
                try { if (stdin != null) stdin.close(); } catch (Exception ignored) {}
                try {
                    if (process != null && process.isAlive()) {
                        process.destroy();
                        if (!process.waitFor(1500, java.util.concurrent.TimeUnit.MILLISECONDS)) {
                            process.destroyForcibly();
                            process.waitFor(1500, java.util.concurrent.TimeUnit.MILLISECONDS);
                        }
                    }
                } catch (Exception ignored) {
                    try { if (process != null && process.isAlive()) process.destroyForcibly(); } catch (Exception ignored2) {}
                }
                dispose();
                System.exit(0);
            }
        });
        // Last-resort child-process cleanup for JVM shutdown (normal close,
        // console termination, or Windows session shutdown). This only owns the
        // backend Process spawned by this DBJ Live window; it never enumerates or
        // kills unrelated java/javaw processes.
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            running = false;
            try { if (stdin != null) stdin.close(); } catch (Exception ignored) {}
            try {
                if (process != null && process.isAlive()) {
                    process.destroy();
                    if (!process.waitFor(1200, java.util.concurrent.TimeUnit.MILLISECONDS)) process.destroyForcibly();
                }
            } catch (Exception ignored) {
                try { if (process != null && process.isAlive()) process.destroyForcibly(); } catch (Exception ignored2) {}
            }
        }, "dbj-live-backend-shutdown"));
        setTitle("Deterministic Blackjack — Live Graphical Companion • " + UI_VERSION);
        replaceVisibleVersionText(getContentPane());
        installStatsCornerButton();
        installPercentileWagerPanel();
        installScaledWagerPanel();
        installControllerLifecycleRail();
        installLiveHumanIdentity();
        rebalanceLiveTableAndTranscript();
        rebalanceHistoryMemoryPanels();
        installVisibleFrequencyPanel();
        installConcentrationControls();
        installLiveV2ObservationPanel();
        // v15.10.318-UAT P002: keep long authoritative CLI prompts fully visible in the one-line status strip.
        // Presentation only: the backend prompt text itself is unchanged.
        javax.swing.Timer promptFitTimer=new javax.swing.Timer(250,e->{
            String t=promptLabel.getText()==null?"":promptLabel.getText();
            int sz=t.length()>105?11:(t.length()>72?12:16);
            if(promptLabel.getFont().getSize()!=sz) promptLabel.setFont(new Font("SansSerif",Font.BOLD,sz));
        });
        promptFitTimer.start();
        getRootPane().putClientProperty("promptFitTimer",promptFitTimer);
        // Keep the established right-hand live area untouched.  Card-count context
        // is shown only as the new bottom row in Recent Player Experience.
    }

    // v15.10.65: QUICK INPUT is a transient entry field. Once Enter/SEND submits
    // the typed value to the authoritative backend, clear it immediately. This is
    // deliberately prompt-agnostic so dealer sequences such as AC,8D,10H cannot
    // remain visible while the backend advances to CARD STREAM REVIEW.
    @Override
    void submitTyped() {
        final String submitted = terminalInput.getText().trim();
        lastOperatorInput = submitted;
        // v15.10.162-UAT: card identity must follow the active one-line GUI prompt, not
        // the accumulated backend transcript. currentPrompt() can contain earlier deal-order
        // text (including "Player card 1"), which previously caused P2 to overwrite P1.
        String activePrompt = promptLabel.getText()==null ? "" : promptLabel.getText();
        String beforePrompt=activePrompt.toLowerCase(Locale.ROOT);
        if(beforePrompt.contains("use a preamble") && submitted.equalsIgnoreCase("N")) { livePreambleFlow=false; livePreambleBranch=""; }
        // v15.10.207-UAT: PREAMBLE CONTEXT C enters the preamble workflow before the
        // backend emits UNIVERSAL PREAMBLE INITIALIZATION. Keep that GUI state explicit
        // so the setup prompts cannot fall through to ordinary Live wager routing.
        if(beforePrompt.contains("preamble context:") && submitted.equalsIgnoreCase("C")) {
            livePreambleFlow=true; livePreambleBranch="";
        }
        if(beforePrompt.contains("preamble source:") && submitted.matches("[123]")) {
            livePreambleFlow=true;
            livePreambleBranch=submitted.equals("1")?"LIVE NOW":submitted.equals("2")?"SCREENSHOTS":"PREVIOUS SESSION";
        }
        // Universal preamble platform selection occurs before applyPreambleSetup(), so the
        // normal backend PLATFORM DECLARED marker does not yet exist. Capture the operator's
        // choice at the GUI boundary for correct Paddy Power provenance/breadcrumbs.
        // v15.10.214-UAT: platform selection must survive the condensed GUI prompt.
        // The visible prompt can be reduced to "Platform [1/2/3]:", so accept either
        // the explicit one-shot state or the visible PLATFORM breadcrumb/prompt boundary.
        boolean preamblePlatformBoundary = awaitingPreamblePlatformChoice
                || (livePreambleFlow && beforePrompt.contains("platform [1/2/3]"))
                || (livePreambleFlow && liveBreadcrumb.toUpperCase(Locale.ROOT).endsWith("→ PLATFORM"));
        if(livePreambleFlow && preamblePlatformBoundary && submitted.matches("[123]")) {
            paddyPowerLive=submitted.equals("2");
            awaitingPreamblePlatformChoice=false;
        }
        // v15.10.216-UAT: capture the two preamble money fields only from their exact
        // operator prompts. The broad BANKROLL match allowed the table minimum to
        // overwrite the already accepted research bankroll at WAGER SETUP.
        // v15.10.215-UAT: the one-line prompt label can be shortened independently of
        // the transcript.  The breadcrumb is therefore the authoritative GUI phase
        // boundary for these two distinct money inputs.  Never allow WAGER SETUP to
        // write the research-bankroll field.
        String crumbUpper=liveBreadcrumb==null?"":liveBreadcrumb.toUpperCase(Locale.ROOT);
        boolean preambleBankrollPrompt = beforePrompt.contains("bankroll to use for this preamble")
                || beforePrompt.contains("enter a different preamble bankroll")
                || (livePreambleFlow && crumbUpper.endsWith("→ BANKROLL"));
        boolean preambleIncrementPrompt = beforePrompt.contains("smallest wager available on this table")
                || (livePreambleFlow && crumbUpper.endsWith("→ WAGER SETUP"));
        if(livePreambleFlow && preambleBankrollPrompt && submitted.matches("[0-9]+(?:\\.[0-9]+)?"))
            livePreambleBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(submitted));
        if(livePreambleFlow && preambleIncrementPrompt && submitted.matches("[0-9]+(?:\\.[0-9]+)?"))
            livePreambleIncrement=String.format(Locale.ROOT,"%.2f",Double.parseDouble(submitted));
        if((beforePrompt.contains("mode:") || beforePrompt.contains("[p] personal") || beforePrompt.contains("[f] frozen") || beforePrompt.contains("[h] hybrid")) && submitted.matches("(?i)[PFH]")) liveMode=submitted.equalsIgnoreCase("P")?"PERSONAL":submitted.equalsIgnoreCase("F")?"FROZEN":"HYBRID";
        if((beforePrompt.contains("current platform balance") || beforePrompt.contains("platform balance with no wager committed"))
                && submitted.matches("[0-9]+(?:\\.[0-9]+)?"))
            livePlatformBalance=String.format(Locale.ROOT,"%.2f",Double.parseDouble(submitted));
        if((beforePrompt.contains("bankroll you want to use for this session") || beforePrompt.contains("formal opening session bankroll") || beforePrompt.contains("formal opening research bankroll") || beforePrompt.contains("choose a larger session bankroll") || beforePrompt.contains("choose a compatible session bankroll") || beforePrompt.contains("choose a session bankroll up to")) && submitted.matches("[0-9]+(?:\\.[0-9]+)?")) liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(submitted));
        // v15.10.319-UAT: capture the operator-entered wager at the GUI input boundary.
        // The backend correctly validates the wager (for example "Wager: MATCH reference"),
        // but the GUI badge needs the active value before card entry begins.
        if(beforePrompt.contains("enter your wager") && submitted.matches("[0-9]+(?:\\.[0-9]+)?")) {
            // New wager = new hand boundary. Retained split presentation state must never
            // leak from the preceding completed split round into the next ordinary hand.
            liveSplitAOutcome=""; liveSplitBOutcome="";
            liveSplitACards.clear(); liveSplitBCards.clear();
            liveSplitADoubleCard=""; liveSplitBDoubleCard="";
            liveSplitHand="";
            double w=Double.parseDouble(submitted);
            currentBaseWagerValue=w;
            currentCommittedWagerValue=w;
            currentWagerText="£"+String.format(Locale.ROOT,"%.2f",w);
            SwingUtilities.invokeLater(() -> {
                wagerBadge.setText("CURRENT WAGER: "+currentWagerText);
                wagerBadge.revalidate();
                wagerBadge.repaint();
            });
        }
        // v15.10.170-UAT: DOUBLE is committed when the operator submits D at the
        // authoritative action prompt.  Settlement already uses the doubled stake;
        // keep the presentation badge synchronized immediately at that transition.
        if(beforePrompt.contains("actual action") && submitted.equalsIgnoreCase("D") &&
           currentBaseWagerValue==currentBaseWagerValue) {
            // Normal DOUBLE: base -> 2x.  DOUBLE AFTER SPLIT: the split already
            // commits 2x, so doubling one child adds one further base stake -> 3x.
            if(splitActive && currentCommittedWagerValue==currentCommittedWagerValue)
                currentCommittedWagerValue += currentBaseWagerValue;
            else
                currentCommittedWagerValue = currentBaseWagerValue*2.0;
            currentWagerText="£"+String.format(Locale.ROOT,"%.2f",currentCommittedWagerValue);
            SwingUtilities.invokeLater(() -> {
                wagerBadge.setText("CURRENT WAGER: "+currentWagerText);
                wagerBadge.revalidate();
                wagerBadge.repaint();
            });
        }
        // v15.10.174-UAT: SPLIT commits a second stake equal to the base wager.
        // Present total committed round exposure immediately and preserve it through
        // Split A/B, dealer, result and balance-check until the next-hand reset.
        if(beforePrompt.contains("actual action") && submitted.equalsIgnoreCase("P") &&
           currentBaseWagerValue==currentBaseWagerValue) {
            currentCommittedWagerValue=currentBaseWagerValue*2.0;
            currentWagerText="£"+String.format(Locale.ROOT,"%.2f",currentCommittedWagerValue);
            SwingUtilities.invokeLater(() -> {
                wagerBadge.setText("CURRENT WAGER: "+currentWagerText);
                wagerBadge.revalidate();
                wagerBadge.repaint();
            });
        }
        // v15.10.162-UAT r2: use an explicit backend-prompt state machine.  The visible
        // prompt label is presentation text and is not authoritative enough to identify card slots.
        // v15.10.188-UAT: a card-shaped operator response is presentation-authoritative
        // only while the ACTIVE backend prompt is requesting that card.  Never use a
        // stale expectedLiveCardSlot to mutate the visible hand at menu/deviation/retry
        // prompts (e.g. accidental "5D" at Reason for action deviation).
        // v15.10.189-UAT: BlackjackLiveGuiOld also carries a legacy lastAwaitingCardRole.
        // That role can survive beyond the card prompt and causes card-shaped free text
        // (for example 5D in a deviation note) to overwrite the visible hand inside
        // super.submitTyped().  Clear that legacy role BEFORE delegation whenever the
        // prompt that existed at SEND time is not itself an authoritative card prompt.
        final boolean authoritativeCardPrompt =
            beforePrompt.matches("(?s).*player card 1\\s*:\s*$") ||
            beforePrompt.matches("(?s).*dealer upcard\\s*:\s*$") ||
            beforePrompt.matches("(?s).*player card 2\\s*:\s*$") ||
            beforePrompt.matches("(?s).*double card\\s*:\s*$") ||
            beforePrompt.matches("(?is).*main hit card\\s*:\s*$");
        if(!authoritativeCardPrompt) {
            try {
                java.lang.reflect.Field f=BlackjackLiveGuiOld.class.getDeclaredField("lastAwaitingCardRole");
                f.setAccessible(true);
                f.set(this,null);
            } catch(Exception ignored) {}
        }
        final boolean submittedCard = submitted.matches("(?i)(?:10|[2-9AJQK])[SHDC]");
        if(beforePrompt.matches("(?s).*player card 1\\s*:\s*$") && submittedCard) { liveP1=submitted; liveDoubleCard=""; liveHitCards.clear(); expectedLiveCardSlot=""; }
        else if(beforePrompt.matches("(?s).*dealer upcard\\s*:\s*$") && submittedCard) { liveUp=submitted; expectedLiveCardSlot=""; }
        else if(beforePrompt.matches("(?s).*player card 2\\s*:\s*$") && submittedCard) { liveP2=submitted; expectedLiveCardSlot=""; }
        else if(beforePrompt.contains("double card") && submittedCard) liveDoubleCard=submitted;
        else if(beforePrompt.matches("(?is).*main hit card\\s*:\s*$") && submittedCard) liveHitCards.add(submitted);
        else if(beforePrompt.contains("dealer hidden/draw sequence")) {
            // v15.10.202-UAT: invalid dealer batches must never pass through the
            // inherited GUI staging path. Send them directly to the authoritative
            // CLI so it can reject them without mutating visible gameplay state.
            if (!dealerBatchPrefixValidForPresentation(liveUp, submitted)) {
                liveDealerSeq="";
                lastDealerSequenceTyped="";
                terminalInput.setText("");
                submit(submitted);
                terminalInput.requestFocusInWindow();
                return;
            }
            // v15.10.238-UAT: do not publish dealer cards merely because the batch is
            // syntactically/prefix valid. The CLI may still reject it as incomplete.
            // Commit only when appendBackend observes the authoritative card-stream
            // review boundary after dealer settlement validation has passed.
            pendingDealerSequence=submitted;
            liveDealerSeq="";
        }
        else if(beforePrompt.contains("result w/l/p") || beforePrompt.contains("choose result")) {
            liveLastResult=submitted.toUpperCase(Locale.ROOT);
            // v15.10.230-UAT: capture the complete physical hand while inherited live
            // card state is still intact. HAND N COMPLETE can arrive only after that
            // state has been cleared, which previously left PREVIOUS HAND as none yet.
            completedHandPresentationSnapshot.clear();
            java.util.List<String> completedCards=currentPhysicalCards();
            if(completedCards!=null) completedHandPresentationSnapshot.addAll(completedCards);
            // v15.10.235-UAT: snapshot the resolved ordinary-hand card count at the
            // result boundary. Later preamble/next-hand presentation resets must not
            // erase the 3 / 4+ value before Recent Player Analysis commits it.
            pendingPlayerCardCount=currentSinglePlayerCardCount();
            pendingHand=currentHand;
        }
        super.submitTyped();

	// CURRENT WAGER refresh after validated wager submission.  Preserve any
// committed DOUBLE amount already published above.
if (currentBaseWagerValue == currentBaseWagerValue) {
    // v15.10.171-UAT: once DOUBLE is committed, preserve the committed stake
    // through double-card, dealer, result and balance-check input.  The hand-boundary
    // reset remains the only place that clears it for the next hand.
    final double displayWager = (currentCommittedWagerValue == currentCommittedWagerValue)
        ? currentCommittedWagerValue : currentBaseWagerValue;
    SwingUtilities.invokeLater(() -> {
        wagerBadge.setText(
            "CURRENT WAGER: £" +
            String.format(Locale.ROOT, "%.2f", displayWager)
        );
        wagerBadge.revalidate();
        wagerBadge.repaint();
    });
}


        // Clear immediately, then clear once more on the Swing queue. The inherited
        // backend bridge can asynchronously echo/re-stage the just-submitted dealer
        // sequence while it advances into CARD STREAM REVIEW. A second pass prevents
        // stale cards from remaining in QUICK INPUT at the following Y/N prompt.
        terminalInput.setText("");
        SwingUtilities.invokeLater(() -> {
            String now=terminalInput.getText().trim();
            if(now.equalsIgnoreCase(submitted) ||
               (lastDealerSequenceTyped!=null && now.equalsIgnoreCase(lastDealerSequenceTyped.trim())))
                terminalInput.setText("");
            terminalInput.requestFocusInWindow();
        });
        terminalInput.requestFocusInWindow();
    }

    private boolean paddyPowerLiveSource() {
        return paddyPowerLive;
    }

    private String livePlatformCrumb() {
        return paddyPowerLiveSource() ? "PADDY POWER" : "247BLACKJACK";
    }

    @Override
    void refreshCurrentTable() {
        super.refreshCurrentTable();
        if(!paddyPowerLiveSource()) return;

        // Paddy Power CashOut observed deal/table presentation:
        // dealer upcard on the left, dealer hole card on the right.
        // Raw card capture is unchanged; this is the source-specific live
        // presentation/reconstruction boundary only.
        dealerVisual.removeAll();
        if(up!=null)
            dealerVisual.add(new VisualCard(up,46,64));
        if(dealerHole!=null)
            dealerVisual.add(new VisualCard(dealerHole,46,64,true));
        else if(up!=null)
            dealerVisual.add(new VisualCard("BACK",46,64,false));
        for(String c:dealerDraws) dealerVisual.add(new VisualCard(c,46,64));
        dealerVisual.revalidate();
        dealerVisual.repaint();
        updateLiveSourceHeading();
    }

    private void updateLiveSourceHeading() {
        String wanted=paddyPowerLiveSource()
            ? "PADDY POWER VISUAL CHECK  •  TYPE CARD → SEE CARD  •  DEALER HIDDEN CARD IS RIGHT OF UPCARD"
            : "247 VISUAL CHECK  •  TYPE CARD → SEE CARD  •  DEALER HIDDEN CARD IS LEFT OF UPCARD";
        replaceLiveSourceHeading(getContentPane(),wanted);
    }

    private void replaceLiveSourceHeading(Component c,String wanted) {
        if(c instanceof JLabel){
            JLabel l=(JLabel)c;
            String t=l.getText();
            if(t!=null && (t.contains("247 VISUAL CHECK") || t.contains("PADDY POWER VISUAL CHECK")))
                l.setText(wanted);
        }
        if(c instanceof Container)
            for(Component x:((Container)c).getComponents()) replaceLiveSourceHeading(x,wanted);
    }

    private void rebalanceLiveTableAndTranscript() {
        // The original live table was 172 px high.  The extra recent-experience row
        // needs a little more room, so grow the table enough for split-hand cards and let the central
        // transcript scroll pane give up the same space.  The transcript remains
        // comfortably usable and the dealer/player card rows keep their normal height.
        Container c = dealerVisual;
        while (c != null) {
            Dimension d = c.getPreferredSize();
            if (c instanceof JPanel && d != null && d.height >= 160 && d.height <= 190) {
                c.setPreferredSize(new Dimension(Math.max(0, d.width), 285));
                c.setMinimumSize(new Dimension(0, 270));
                break;
            }
            c = c.getParent();
        }
        revalidate();
    }


    // UAT v15.10.69: live dealer-hole memory is intentionally bounded to the
    // most recent TWO visual rows (8 cards per row in the inherited renderer).
    // The authoritative dealerHoleHistory list is NOT truncated: this is a
    // presentation/cognitive-load boundary only. Older hole cards remain in
    // evidence for later audit/replay but are not scrollable during live play.
    @Override
    void addWrappedHiddenCards(JPanel panel, java.util.List<String> cards, int cardW, int cardH, int perRow) {
        if(cards==null || cards.isEmpty()) return;
        int maxVisible=Math.max(1, perRow)*2;
        int from=Math.max(0, cards.size()-maxVisible);
        super.addWrappedHiddenCards(panel, new ArrayList<>(cards.subList(from,cards.size())), cardW, cardH, perRow);
    }

    private void rebalanceHistoryMemoryPanels() {
        // UAT v15.10.69: less-is-more live memory boundary: dealer-hole display
        // is capped at two rows; unused right-side space is deliberately left blank.
        // UAT v15.10.68: retains v15.10.67 layout; embedded CLI adds structured replay-timed mindset evidence.
        // UAT v15.10.67: reclaim unused vertical space from PREVIOUS HAND without
        // shrinking its cards, and give that space to dealer hole-card memory.
        JScrollPane previousScroll=(JScrollPane)SwingUtilities.getAncestorOfClass(JScrollPane.class,streamCards);
        if(previousScroll!=null){
            previousScroll.setPreferredSize(new Dimension(395,160));
            previousScroll.setMaximumSize(new Dimension(Integer.MAX_VALUE,170));
        }
        JScrollPane hiddenScroll=(JScrollPane)SwingUtilities.getAncestorOfClass(JScrollPane.class,hiddenCards);
        if(hiddenScroll!=null){
            hiddenScroll.setPreferredSize(new Dimension(395,132));
            hiddenScroll.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));
            hiddenScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER);
        }
        revalidate(); repaint();
    }

    private void installVisibleFrequencyPanel() {
        visibleFrequencyPanel.setOpaque(true);
        visibleFrequencyPanel.setBackground(new Color(245,248,251));
        visibleFrequencyPanel.setBorder(BorderFactory.createEmptyBorder(2,6,2,6));

        visibleFrequencyLegend.setFont(new Font("SansSerif",Font.BOLD,16));
        visibleFrequencyLegend.setText("<html><span style='color:#197D4B'>↑ High</span>&nbsp;&nbsp;&nbsp;&nbsp;<span style='color:#BE3732'>↓ Low</span>&nbsp;&nbsp;&nbsp;&nbsp;<span style='color:#466078'>— Neutral</span></html>");
        visibleFrequencyCounts.setFont(new Font("SansSerif",Font.BOLD,20));

        visibleFrequencyPanel.add(visibleFrequencyLegend,BorderLayout.NORTH);
        visibleFrequencyPanel.add(visibleFrequencyCounts,BorderLayout.CENTER);
        getLayeredPane().add(visibleFrequencyPanel,JLayeredPane.PALETTE_LAYER);
        repositionVisibleFrequencyPanel();

        javax.swing.Timer t=new javax.swing.Timer(250,e->{
            refreshVisibleFrequencyCounts();
            repositionVisibleFrequencyPanel();
        });
        t.start();
        visibleFrequencyPanel.putClientProperty("refreshTimer",t);
    }

    private void repositionVisibleFrequencyPanel() {
        if(visibleFrequencyPanel==null || recentCards==null || recentCards.getParent()==null)return;
        try {
            Rectangle r=SwingUtilities.convertRectangle(recentCards.getParent(),recentCards.getBounds(),getLayeredPane());
            int w=Math.min(430,Math.max(300,r.width-8));
            int x=r.x+Math.max(0,r.width-w-4);
            int y=Math.max(50,r.y-72);
            visibleFrequencyPanel.setBounds(x,y,w,58);
            visibleFrequencyPanel.revalidate(); visibleFrequencyPanel.repaint();
        } catch(Exception ignored) {}
    }

    private void installConcentrationControls() {
        concentrationControls.setOpaque(false);
        Font f=new Font("SansSerif",Font.BOLD,11);
        for(JCheckBox cb:new JCheckBox[]{handAnalysisCheck,reviewNotesCheck,controllerEventsCheck}){
            cb.setOpaque(false); cb.setFont(f); cb.setFocusable(false);
            concentrationControls.add(cb);
        }
        concentrationControls.setToolTipText("Display choices take effect from the next hand; evidence and calculations continue underneath.");
        getLayeredPane().add(concentrationControls,JLayeredPane.PALETTE_LAYER);
        javax.swing.Timer t=new javax.swing.Timer(300,e->repositionConcentrationControls());
        t.start(); concentrationControls.putClientProperty("positionTimer",t);
        repositionConcentrationControls();
    }

    private void repositionConcentrationControls() {
        try {
            // v15.10.197-UAT: keep controls inside the lower blank band of RECENT CARDS.
            // This is visually underneath the cards but cannot collide with the PREVIOUS HAND heading.
            if(recentCards==null || recentCards.getParent()==null)return;
            Rectangle r=SwingUtilities.convertRectangle(recentCards.getParent(),recentCards.getBounds(),getLayeredPane());
            int w=Math.min(470,Math.max(360,r.width-18));
            int h=24;
            int x=r.x+Math.max(8,(r.width-w)/2);
            // v15.10.199-UAT: place controls below the card band rather than over the cards.
            int y=r.y+r.height-8;
            concentrationControls.setBounds(x,y,w,h);
            concentrationControls.revalidate(); concentrationControls.repaint();
        } catch(Exception ignored) {}
    }

    private void latchConcentrationForHand(String hand) {
        if(hand==null || hand.isBlank() || hand.equals(concentrationLatchedHand)) return;
        concentrationLatchedHand=hand;
        activeHandAnalysis=handAnalysisCheck.isSelected();
        activeReviewNotes=reviewNotesCheck.isSelected();
        activeControllerEvents=controllerEventsCheck.isSelected();
        lastAutoSuppressedPrompt="";
        visibleFrequencyLegend.setVisible(activeHandAnalysis);
        visibleFrequencyCounts.setVisible(activeHandAnalysis);
    }

    private String suppressConcentrationOutput(String shown) {
        if(shown==null || shown.isEmpty()) return shown;
        if(activeHandAnalysis && activeControllerEvents) return shown;
        StringBuilder out=new StringBuilder();
        for(String line: shown.split("\\R",-1)) {
            String t=line.trim().toUpperCase(Locale.ROOT);
            if(!activeHandAnalysis && (t.startsWith("OBSERVED HAND") || t.startsWith("CURRENT STARTING HAND") ||
               t.startsWith("PREVIOUS MATCHES") || t.startsWith("PREVIOUS ") ||
               t.startsWith("ALL PREVIOUS OCCURRENCES") || t.startsWith("CARD MIX ") ||
               t.startsWith("LOW=2-6"))) continue;
            if(!activeControllerEvents && (t.startsWith("COMPARISON CONTROLLER EXIT") ||
               (t.startsWith("REASON:") && shown.toUpperCase(Locale.ROOT).contains("COMPARISON CONTROLLER EXIT")))) continue;
            out.append(line).append('\n');
        }
        if(out.length()>0) out.setLength(out.length()-1);
        return out.toString();
    }

    private void autoAnswerSuppressedPrompts(String text) {
        if(text==null) return;
        String clean=text.replaceAll("\\u001B\\[[0-9;?]*[ -/]*[@-~]", "").trim();
        String low=clean.toLowerCase(Locale.ROOT);
        String answer=null, key=null;
        // v15.10.195-UAT: concentration controls suppress optional presentation only.
        // Reconstruction and Amend remain mandatory evidence-integrity safeguards.
        if(!activeReviewNotes && low.contains("any final comments, evidence-quality notes, or next-hand intention") && low.endsWith("[y/n]:")) { answer="N"; key="comments:"+liveCurrentHand; }
        else if(!activeHandAnalysis && low.endsWith("show stat-watching information after each completed hand? [y/n]:")) { answer="N"; key="statwatch-session"; }
        else if(!activeHandAnalysis && low.contains("view stat-watching observation after hand") && low.endsWith("[y/n]:")) { answer="N"; key="statwatch-hand:"+liveCurrentHand; }
        if(answer==null || key.equals(lastAutoSuppressedPrompt)) return;
        lastAutoSuppressedPrompt=key;
        final String a=answer;
        SwingUtilities.invokeLater(() -> { terminalInput.setText(a); submitTyped(); });
    }

    private void refreshVisibleFrequencyCounts() {
        int high=0,low=0,neutral=0;
        java.util.List<String> visible=new ArrayList<>();
        // v15.10.193-UAT: CURRENT visible hand means this hand only.  The previous
        // hand has its own panel below and must never leak into these counters.
        java.util.List<String> current=currentDisplayStream();
        if(current!=null)visible.addAll(current);
        for(String card:visible){
            if(card==null || card.isBlank() || "BACK".equalsIgnoreCase(card))continue;
            String marker=bucketMarker(card);
            if("↑".equals(marker))high++;
            else if("↓".equals(marker))low++;
            else neutral++;
        }
        visibleFrequencyCounts.setText("<html><span style='color:#197D4B'>↑ HIGH "+high+"</span>&nbsp;&nbsp;&nbsp;&nbsp;<span style='color:#BE3732'>↓ LOW "+low+"</span>&nbsp;&nbsp;&nbsp;&nbsp;<span style='color:#466078'>— NEUTRAL "+neutral+"</span></html>");
    }


    private void sanitizeQuickInputForCardReview(String backendText) {
        String lower=backendText==null?"":backendText.toLowerCase(Locale.ROOT);
        boolean review=lower.contains("card stream review") ||
                       lower.contains("does this reconstructed card order correctly represent the hand?");
        if(!review)return;
        String raw=terminalInput.getText().trim();
        if(raw.isEmpty())return;
        String stale=lastDealerSequenceTyped==null?"":lastDealerSequenceTyped.trim();
        if(!stale.isEmpty()){
            if(raw.equalsIgnoreCase(stale)){ terminalInput.setText(""); return; }
            String u=raw.toUpperCase(Locale.ROOT), su=stale.toUpperCase(Locale.ROOT);
            if(u.startsWith(su)){
                String tail=raw.substring(stale.length()).trim();
                while(tail.startsWith(",")||tail.startsWith(";"))tail=tail.substring(1).trim();
                if(tail.equalsIgnoreCase("Y")||tail.equalsIgnoreCase("N")||
                   tail.equalsIgnoreCase("YES")||tail.equalsIgnoreCase("NO"))
                    terminalInput.setText(tail);
                else terminalInput.setText("");
                return;
            }
        }
        // At a reconstructed-order Y/N prompt, card-shaped residue is never a valid
        // answer. Preserve only an explicit final Y/N token if the operator typed ahead.
        Matcher m=Pattern.compile("(?i)(?:^|[,;\\s])(Y|N|YES|NO)\\s*$").matcher(raw);
        if(m.find())terminalInput.setText(m.group(1));
        else if(raw.matches("(?i).*[2-9AJQK10][SHDCU?].*"))terminalInput.setText("");
    }

    // v15.10.144-UAT: Live setup is presented as a guided operator workflow.
    // Backend/evidence text remains authoritative; only the visible transcript is
    // condensed. Major steps and validation retries flush the visual transcript,
    // then redraw breadcrumb + essential context + one concise prompt.
    private String liveBreadcrumb = "MAIN MENU";
    private String liveSessionIdentity = "";
    private String liveSessionBankroll = "—";
    private boolean flushOperatorTranscript = false;
    private boolean livePreambleFlow = false;
    // v15.10.210-UAT: the visible GUI rewrites the backend platform prompt to a concise
    // "Choose [1/2/3]:" label. Remember that this exact next submission is the preamble
    // platform choice instead of trying to rediscover the hidden backend wording later.
    private boolean awaitingPreamblePlatformChoice = false;
    private String livePreambleBranch = "";
    // v15.10.213-UAT: preserve operator-entered preamble setup values across condensed prompts.
    private String livePreambleBankroll="";
    private String livePreambleIncrement="";
    private String liveCurrentHand = "1";
    private String livePlatformBalance = "—";
    private String lastOperatorInput = "";
    private String liveP1 = "", liveUp = "", liveP2 = "", liveDoubleCard = "", liveDealerSeq = "";
    // v15.10.238-UAT: dealer entry is transactional. Typed/rejected/incomplete batches
    // stay pending until the backend reaches its authoritative card-stream review.
    private String pendingDealerSequence = "";
    // v15.10.199-UAT: presentation-only capture of ordinary post-deal HIT cards.
    // Authoritative evidence remains in the CLI/session report; this prevents the concise
    // RESULT/COMPLETE renderer from falling back to the initial two-card hand.
    private final java.util.List<String> liveHitCards = new ArrayList<>();
    private String expectedLiveCardSlot = "";
    private String liveLastResult = "";
    private boolean liveLinkedPreamble = false;

    private String prettyCard(String c) {
        if(c==null) return "";
        String x=c.trim().toUpperCase(Locale.ROOT);
        if(x.length()<2) return x;
        char suit=x.charAt(x.length()-1);
        String rank=x.substring(0,x.length()-1);
        String sym=suit=='H'?"♥":suit=='D'?"♦":suit=='C'?"♣":suit=='S'?"♠":String.valueOf(suit);
        return rank+sym;
    }

    private String modeCrumb() {
        String m=(liveMode==null||liveMode.isBlank()||liveMode.equals("?"))?"MODE":liveMode.toUpperCase(Locale.ROOT);
        String base="MAIN MENU → LIVE SESSION" + (liveLinkedPreamble ? " → PREAMBLE LINKED" : "") + " → MODE → " + m;
        return base;
    }

    private void captureLiveSessionIdentity(String text) {
        if(text==null) return;
        Matcher m=Pattern.compile("(?im)^SESSION:\s*(.+?)\s*$").matcher(text);
        if(!m.find()) return;
        String full=m.group(1).trim();
        Matcher n=Pattern.compile("(?i)\\bSESSION\\s+(\\d+)\\b").matcher(full);
        liveSessionIdentity=n.find()?"SESSION "+n.group(1):full;
        if(liveSessionIdentity.length()>28) liveSessionIdentity=liveSessionIdentity.substring(0,28).trim();
    }

    // v15.10.153: keep the concise live renderer tied to the complete authoritative
    // hand, not merely the most recently typed fragment.  The backend transcript is
    // still the evidence authority; these fields are presentation state only.
    private void syncLiveHandFromTranscript(String text) {
        if(text==null) return;
        // v15.10.224-UAT: backend-confirmed mode is authoritative. The condensed GUI
        // prompt can change before submitTyped() observes it, so recover the selected
        // mode from the CLI markers rather than ever presenting MODE -> ?.
        String modeText=text.toUpperCase(Locale.ROOT);
        if(modeText.contains("\nFROZEN MODE") || modeText.contains("\nFROZEN |")) liveMode="FROZEN";
        else if(modeText.contains("\nHYBRID |")) liveMode="HYBRID";
        else if(modeText.contains("\nPERSONAL |")) liveMode="PERSONAL";
        Matcher m=Pattern.compile("(?im)^Player card 1:\s*([0-9AJQK]+[SHDC])\s*$").matcher(text); if(m.find()) liveP1=m.group(1);
        m=Pattern.compile("(?im)^Dealer upcard:\s*([0-9AJQK]+[SHDC])\s*$").matcher(text); if(m.find()) liveUp=m.group(1);
        m=Pattern.compile("(?im)^Player card 2:\s*([0-9AJQK]+[SHDC])\s*$").matcher(text); if(m.find()) liveP2=m.group(1);
        m=Pattern.compile("(?im)^>\s*([0-9AJQK]+[SHDC](?:\s*,\s*[0-9AJQK]+[SHDC])*)\s*$").matcher(text);
        while(m.find()) {
            String candidate=m.group(1).replaceAll("\\s+","");
            // v15.10.238-UAT: transcript echoes are never dealer-card commit authority.
            // A syntactically valid but incomplete batch can be echoed before the CLI
            // rejects it for settlement. Keep it pending until card-stream review.
            if(dealerBatchPrefixValidForPresentation(liveUp,candidate)) pendingDealerSequence=candidate;
        }
    }

    private java.util.List<String> liveOrdinaryPlayerCards() {
        java.util.List<String> cards=new ArrayList<>();
        if(!liveP1.isBlank()) cards.add(liveP1);
        if(!liveP2.isBlank()) cards.add(liveP2);
        cards.addAll(liveHitCards);
        if(!liveDoubleCard.isBlank()) cards.add(liveDoubleCard);
        return cards;
    }

    private String prettyCards(java.util.List<String> cards) {
        StringBuilder b=new StringBuilder();
        for(String c:cards){ if(b.length()>0)b.append(" "); b.append(prettyCard(c)); }
        return b.toString();
    }

    private boolean dealerBatchPrefixValidForPresentation(String upcard, String batch) {
        if(upcard==null || upcard.isBlank() || batch==null || batch.isBlank() || batch.trim().equals("?")) return true;
        java.util.List<String> prefix=new ArrayList<>();
        prefix.add(upcard.trim());
        String[] tokens=batch.split(",");
        for(int i=0;i<tokens.length;i++) {
            String token=tokens[i].trim();
            if(token.isBlank()) return true; // backend owns syntax rejection
            if(i>0 && displayHandTotal(prefix.toArray(new String[0]))>=17) return false;
            prefix.add(token);
        }
        return true;
    }

    private int displayHandTotal(String... cards) {
        int total=0, aces=0;
        for(String c:cards){
            if(c==null||c.isBlank()) continue;
            String x=c.trim().toUpperCase(Locale.ROOT);
            if(x.length()<2) continue;
            String r=x.substring(0,x.length()-1);
            if(r.equals("A")){ total+=11; aces++; }
            else if(r.equals("K")||r.equals("Q")||r.equals("J")) total+=10;
            else try{ total+=Integer.parseInt(r); }catch(Exception ignored){}
        }
        while(total>21&&aces-->0) total-=10;
        return total;
    }

    // v15.10.160-UAT: keep the final accepted Frozen bankroll visible downstream.
    // These are the established £15/£20/£30 research-frame proportions.
    private String frozenWagersForBankroll(String bankroll) {
        try {
            double b=Double.parseDouble(bankroll);
            return String.format(Locale.ROOT,"£%.2f / £%.2f / £%.2f",b*0.15,b*0.20,b*0.30);
        } catch(Exception ex) { return "—"; }
    }

    private String formatOperatorTranscript(String raw) {
        flushOperatorTranscript = false;
        captureLiveSessionIdentity(raw);
        if (raw == null || raw.isEmpty()) return raw;
        // v15.10.229-UAT / CLI V9.2.249: the backend reader may deliver text
        // from both sides of an ANSI clear-screen in one accumulated chunk.  The
        // frozen V9.2.246 boundary is authoritative: visually format only content
        // AFTER the final clear command, while appendBackend still receives the
        // complete raw stream for state/evidence parsing.
        int clear2 = raw.lastIndexOf("\u001B[2J");
        int clear3 = raw.lastIndexOf("\u001B[3J");
        int clearAt = Math.max(clear2, clear3);
        if (clearAt >= 0) {
            int end = raw.indexOf('J', clearAt);
            if (end >= 0 && end + 1 < raw.length()) raw = raw.substring(end + 1);
            else if (end >= 0) raw = "";
        }
        String low = raw.toLowerCase(Locale.ROOT);
        if(low.contains("preamble linked | chronology retained")){ liveLinkedPreamble=true; livePreambleFlow=false; }
        if(low.contains("independent session") || low.contains("live session cancelled")) liveLinkedPreamble=false;
        // Capture the authoritative backend card-entry stage before presentation condenses it.
        // A prompt ending in the bare field name is the next input slot; echoed completed lines
        // contain a card value after the colon and therefore do not change the expected slot.
        String rawTrim = raw.replaceAll("\\u001B\\[[0-9;?]*[ -/]*[@-~]", "").trim();
        if(rawTrim.matches("(?is).*Player card 1:\s*$")) expectedLiveCardSlot="P1";
        else if(rawTrim.matches("(?is).*Dealer upcard:\s*$")) expectedLiveCardSlot="UP";
        else if(rawTrim.matches("(?is).*Player card 2:\s*$")) expectedLiveCardSlot="P2";

        Matcher rb=Pattern.compile("Research bankroll:\\s*[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(raw);
        if(rb.find()) liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(rb.group(1)));

        // v15.10.216-UAT: PREAMBLE confirmation values come from the authoritative
        // backend setup-check lines, not transient Swing prompt/input state.  The raw
        // transcript is cumulative, so retain the LAST complete setup check.
        Matcher pcs=Pattern.compile("PREAMBLE SETUP CHECK\\s*\\|.*?research bankroll\\s*£?([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(raw);
        while(pcs.find()) livePreambleBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(pcs.group(1)));
        Matcher pei=Pattern.compile("EXACT FROZEN EQUIVALENTS\\s*\\|.*?platform increment/minimum\\s*£?([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(raw);
        while(pei.find()) livePreambleIncrement=String.format(Locale.ROOT,"%.2f",Double.parseDouble(pei.group(1)));

        // v15.10.174-UAT: consume the backend's explicit split-settlement commit
        // immediately; later hand-complete text can arrive in a separate append.
        Matcher sc=Pattern.compile("SETTLEMENT COMMITTED\\s*\\|\\s*ACTUAL PLATFORM BALANCE\\s*£?([0-9]+(?:\\.[0-9]+)?)\\s*\\|\\s*SESSION BANKROLL\\s*£?([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(raw);
        String committedPlatformBalance=null, committedSessionBankroll=null;
        if(sc.find()){
            committedPlatformBalance=String.format(Locale.ROOT,"%.2f",Double.parseDouble(sc.group(1)));
            committedSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(sc.group(2)));
            livePlatformBalance=committedPlatformBalance;
            liveSessionBankroll=committedSessionBankroll;
            setLegacyCurrentBankrollBadge("£"+liveSessionBankroll);
        }

        Matcher sbx=Pattern.compile("Session bankroll:\\s*[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(raw);
        while(sbx.find()) {
            liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(sbx.group(1)));
            // v15.10.170-UAT: the condensed Hand 1 prompt itself is an authoritative
            // session-bankroll boundary. Seed STARTING/CURRENT immediately if the
            // earlier setup confirmation was emitted before Swing attached.
            if(legacyStartingBankrollUnset())
                setLegacyBankrollBadges("£"+liveSessionBankroll,"£"+liveSessionBankroll);
        }
        // v15.10.180-UAT: raw is the accumulated backend transcript. Older setup
        // "Session bankroll:" lines therefore remain present after settlement and the
        // generic parser above can otherwise overwrite the newer committed value.
        // Re-apply the explicit settlement marker as the final authority for this append.
        if(committedSessionBankroll!=null){
            livePlatformBalance=committedPlatformBalance;
            liveSessionBankroll=committedSessionBankroll;
            setLegacyCurrentBankrollBadge("£"+liveSessionBankroll);
        }

        // v15.10.166-UAT: SESSION LOCK is the authoritative next-hand money boundary.
        Matcher sl=Pattern.compile("SESSION LOCK\\s*\\|.*?Platform\\s*£?([0-9]+(?:\\.[0-9]+)?).*?Session bankroll\\s*£?([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(raw);
        if(sl.find()){
            livePlatformBalance=String.format(Locale.ROOT,"%.2f",Double.parseDouble(sl.group(1)));
            liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(sl.group(2)));
        }
        Matcher vr=Pattern.compile("RESULT VALIDATED.*?(PLAYER WIN|PLAYER LOSS|PUSH)",Pattern.CASE_INSENSITIVE).matcher(raw);
        if(vr.find()){
            String v=vr.group(1).toUpperCase(Locale.ROOT);
            liveLastResult=v.contains("LOSS")?"L":v.contains("WIN")?"W":"P";
        }
        String shown = raw.replaceAll("\\u001B\\[[0-9;?]*[ -/]*[@-~]", "");
        syncLiveHandFromTranscript(shown);

        // UAT v15.10.146: remember workflow context so presentation never invents MODE -> ?
        // and never mistakes a post-hand balance prompt for initial STARTING BALANCE.
        if (low.contains("preamble context:") && low.contains("standalone session")) {
            livePreambleFlow=false; livePreambleBranch="";
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE CONTEXT";
            flushOperatorTranscript=true;
            return liveBreadcrumb+"\n\nPREAMBLE CONTEXT\n[C] CONTINUE WITH CONTEXT\n[S] STANDALONE SESSION:";
        }
        if (low.contains("record/use a preamble") || low.contains("use a preamble before this formal session")) {
            livePreambleFlow=true; livePreambleBranch="";
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nUse a preamble before this formal session? [Y/N]:";
        }
        if (low.contains("preamble evidence source")) {
            livePreambleFlow=true;
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → SOURCE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nWhere are the preamble cards coming from?\n\n[1] Enter them live now\n[2] Reconstruct them from screenshots\n[3] Use the previous live session\n\nChoose [1/2/3]:";
        }
        if (low.contains("universal preamble initialization") || (livePreambleFlow && low.contains("observed blackjack platform"))) {
            if(livePreambleBranch.isBlank()) livePreambleBranch="SETUP";
            awaitingPreamblePlatformChoice=true;
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePreambleBranch+" → PLATFORM";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nWhich table are you observing?\n\n[1] 247Blackjack — Play money\n[2] Paddy Power — Real money\n[3] Other / Unknown\n\nChoose [1/2/3]:";
        }
        if (livePreambleFlow && low.contains("are there currently no wagers/chips committed")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePlatformCrumb()+" → SETUP → STARTING BALANCE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nSTARTING BALANCE\n\nClear all wagers/chips from the table before checking your balance.\n\nAre all wagers/chips cleared? [Y/N]:";
        }
        if (livePreambleFlow && low.contains("platform balance £") && !low.contains("preamble setup check") && !low.contains("exceeds platform balance")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePlatformCrumb()+" → SETUP → STARTING BALANCE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nSTARTING BALANCE\nEnter your current platform balance: £";
        }
        if (livePreambleFlow && low.contains("declared preamble research bankroll represented")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePlatformCrumb()+" → SETUP → BANKROLL";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nPREAMBLE BANKROLL\nBankroll to use for this preamble: £";
        }
        if (livePreambleFlow && low.contains("smallest wager/denomination available")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePlatformCrumb()+" → SETUP → WAGER SETUP";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nWAGER SETUP\nSmallest wager available on this table: £";
        }
        if (livePreambleFlow && low.contains("preamble bankroll rejected") && low.contains("cannot all be represented")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePlatformCrumb()+" → SETUP → WAGER SETUP";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nWAGER SETUP\nThat bankroll doesn't work with this table's minimum wager.\n\nEnter a different preamble bankroll: £";
        }
        if (livePreambleFlow && low.contains("lock this preamble setup before evidence capture")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → "+livePlatformCrumb()+" → SETUP → CONFIRM";
            flushOperatorTranscript=true;
            String preRb=livePreambleBankroll.isBlank()?"—":livePreambleBankroll;
            String inc=livePreambleIncrement.isBlank()?"—":livePreambleIncrement;
            String fw="—";
            if(!livePreambleBankroll.isBlank()){
                double b=Double.parseDouble(livePreambleBankroll);
                fw=String.format(Locale.ROOT,"£%.2f / £%.2f / £%.2f",.15*b,.20*b,.30*b);
            }
            return ""+liveBreadcrumb+"\n\nCONFIRM SETUP\nResearch bankroll: £"+preRb+
                "\nFrozen-equivalent wagers (LOW / MEDIUM / HIGH): "+fw+
                "\nTable minimum/increment: £"+inc+
                "\nWager authority: FROZEN-EQUIVALENT\nAction authority: OBSERVED HUMAN"+
                "\n\nUse this preamble setup? [Y/N]:";
        }
        if (low.contains("new screenshot preamble request created")) {
            livePreambleBranch="SCREENSHOTS";
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → SCREENSHOTS → REQUEST CREATED";
            // Keep the detailed handoff instructions: they are evidence-integrity safeguards.
            return shown;
        }
        if (low.contains("preamble source selected: previous live session")) {
            livePreambleFlow=true; livePreambleBranch="PREVIOUS SESSION";
            return shown;
        }
        if (low.contains("[l] link / [i] independent / [c] cancel")) {
            livePreambleFlow=true; livePreambleBranch="PREVIOUS SESSION";
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → PREVIOUS SESSION";
            flushOperatorTranscript=true;
            Matcher x=Pattern.compile("(\\d+) hands \\| (\\d+) cards",Pattern.CASE_INSENSITIVE).matcher(shown);
            String summary=x.find()?x.group(1)+" hands · "+x.group(2)+" cards":"Previous-session evidence ready";
            return ""+liveBreadcrumb+"\n\nPREVIOUS SESSION\n"+summary+"\n\nHow should this preamble be used?\n\n[L] Link to the new session\n[I] Keep it independent\n[C] Cancel\n";
        }
        if (low.contains("independent start requires resolution")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → PREAMBLE → PREVIOUS SESSION → INDEPENDENT";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nPREAMBLE\nThis preamble must be completed or deleted before continuing independently.\n\n[S] Complete\n[D] Delete\n[C] Cancel\n";
        }
        if (low.contains("live play -> preamble resolution -> s analysis") || low.contains("[a/5] retained evidence integrity")) {
            // Deliberately do not condense technical S-analysis/audit output.
            return shown;
        }
        if ((low.contains("actual platform balance") && low.contains("after hand")) || low.contains("bankroll after hand")) {
            Matcher bh=Pattern.compile("HAND\\s+(\\d+)\\s*/\\s*30",Pattern.CASE_INSENSITIVE).matcher(shown);
            String balanceHand=liveCurrentHand;
            while(bh.find()) balanceHand=bh.group(1);
            if(balanceHand!=null&&!balanceHand.isBlank()) liveCurrentHand=balanceHand;
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → BALANCE CHECK";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\n\nBALANCE CHECK\nEnter the platform balance shown after the result: £";
        }

        if (low.contains("[l] link / [i] independent / [c] cancel")) {
            liveBreadcrumb = "MAIN MENU → LIVE SESSION → PREAMBLE AVAILABLE";
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nAccepted preamble is available.\n\n[L] LINK\n[I] INDEPENDENT\n[C] CANCEL\n\nChoose [L/I/C]:";
        }

        if (low.contains("evidence source [1/2]")) {
            liveBreadcrumb = "MAIN MENU → LIVE SESSION → EVIDENCE SOURCE";
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nEVIDENCE SOURCE\n[1] OBSERVED LIVE CARDS\n[2] SYNTHETIC / CONTROLLED UAT CARDS\n\nEvidence source [1/2]:";
        }
        if (low.contains("mode: [p] personal")) {
            liveBreadcrumb = "MAIN MENU → LIVE SESSION" + (liveLinkedPreamble ? " → PREAMBLE LINKED" : "");
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nMode: [P] PERSONAL / [F] FROZEN / [H] HYBRID:";
        }
        if (low.contains("preamble boundary mismatch") && low.contains("cancel linked session and return to main menu")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → STARTING BALANCE";
            flushOperatorTranscript = true;
            Matcher need=Pattern.compile("linked preamble closing platform balance £?([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            Matcher got=Pattern.compile("formal starting platform balance £?([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String expected=need.find()?need.group(1):"—", entered=got.find()?got.group(1):"—";
            return liveBreadcrumb+"\n\nPREAMBLE BOUNDARY MISMATCH\nLinked preamble closing balance: £"+expected+"\nCurrent platform balance entered: £"+entered+
                   "\n\n[R] Re-enter balance\n[C] Cancel linked session and return to Main Menu\n\nChoose [R/C]:";
        }
        if (low.contains("session name / description")) {
            liveBreadcrumb = modeCrumb();
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nSession name / description:";
        }
        if (low.contains("show an alert if another strategy stops playing?")) {
            liveBreadcrumb = modeCrumb();
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nFROZEN MODE\nFrozen controls the wagers and playing decisions.\n\nShow an alert if another strategy stops playing? [Y/N]:";
        }
        if (low.contains("show stat-watching information after each completed hand?")) {
            liveBreadcrumb = modeCrumb();
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nFROZEN MODE\nFrozen controls the wagers and playing decisions.\n\nShow Stat-Watching information after each completed hand? [Y/N]:";
        }
        if (low.contains("observed blackjack platform")) {
            liveBreadcrumb = modeCrumb() + " → PLATFORM";
            flushOperatorTranscript = true;
            shown=shown.replaceAll("(?m)^PERSONAL \\| your decisions\\s*$", "");
            shown=shown.replace("247Blackjack £ play-money", "247Blackjack — play-money").replace("Paddy Power £ Premium", "Paddy Power — Premium");
            return liveBreadcrumb + "\n\n" + shown;
        }
        if (low.contains("are there currently no wagers/chips committed on the table?")
                && !low.contains("[y/n]: y") && !low.contains("[y/n]: n")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nWAGER SETUP\nBefore reading the balance, remove all wagers/chips from the table.\n\nAre there currently NO wagers/chips committed on the table? [Y/N]:";
        }
        if (low.contains("use a separate session bankroll for this real-money table?")
                && !low.contains("[y/n]: y") && !low.contains("[y/n]: n")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            Matcher pbm=Pattern.compile("Platform balance confirmed: £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String pb=pbm.find()?pbm.group(1):livePlatformBalance;
            if(pb==null || pb.isBlank()) pb="—";
            livePlatformBalance=pb;
            return liveBreadcrumb + "\n\nWAGER SETUP\nPlatform balance: £"+pb+"\n\nUse a separate session bankroll for this real-money table? [Y/N]:";
        }
        if (low.contains("platform declared: paddy power") || low.contains("use declared scaled real-money execution")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nWAGER SETUP\nUse a separate session bankroll for this real-money table? [Y/N]:";
        }
        // v15.10.154-UAT: Frozen wager-floor incompatibility must be shown before the
        // generic bankroll re-prompt.  Otherwise the accumulated backend transcript
        // still contains the earlier "Bankroll you want..." text and the GUI makes
        // a valid Frozen rejection look like an unexplained reset.
        if (low.contains("frozen reference: ineligible") && low.contains("choose a larger research bankroll") && !low.contains("frozen wager floor rejected")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            String entered = lastOperatorInput.isBlank()?"—":lastOperatorInput;
            return ""+liveBreadcrumb+"\n\nWAGER SETUP\nFROZEN REFERENCE: INELIGIBLE\n"
                    +"Table minimum entered: £"+entered+"\n"
                    +"The current Frozen wager set cannot be represented exactly at this table minimum.\n\n"
                    +"Choose a larger session bankroll: £";
        }
        if (low.contains("platform balance with no wager committed")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nWAGER SETUP\nNo wager/chips committed: YES\n\nPlatform balance with NO wager committed: £";
        }
        if (low.contains("session bankroll rejected") && low.contains("exceeds platform balance")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            Matcher pm=Pattern.compile("platform balance £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            Matcher rbm=Pattern.compile("SESSION BANKROLL REJECTED \\| £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String pb=pm.find()?pm.group(1):livePlatformBalance;
            String rejected=rbm.find()?rbm.group(1):(lastOperatorInput.isBlank()?"—":lastOperatorInput);
            if(pb==null || pb.isBlank()) pb="—";
            livePlatformBalance=pb;
            liveSessionBankroll="—";
            return liveBreadcrumb + "\n\nWAGER SETUP\nPlatform balance: £"+pb+"\nRejected session bankroll: £"+rejected
                    +"\n\nSession bankroll cannot exceed platform balance.\nChoose a session bankroll up to £"+pb+": £";
        }
        if (low.contains("frozen wager floor rejected")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            Matcher pm=Pattern.compile("platform balance £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            Matcher tm=Pattern.compile("table minimum £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            Matcher rbm=Pattern.compile("rejected bankroll £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            Matcher fw=Pattern.compile("Frozen wagers £([0-9.]+) / £([0-9.]+) / £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            Matcher nc=Pattern.compile("next compatible bankroll £([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String pb=pm.find()?pm.group(1):"—", tmn=tm.find()?tm.group(1):"—", rejected=rbm.find()?rbm.group(1):"—";
            String wagers=fw.find()?"£"+fw.group(1)+" / £"+fw.group(2)+" / £"+fw.group(3):"—";
            String next=nc.find()?nc.group(1):"";
            livePlatformBalance=pb;
            String suggestion=next.isBlank()?"":"\nNext compatible bankroll: £"+next;
            return liveBreadcrumb + "\n\nWAGER SETUP\nPlatform balance: £"+pb+"\nTable minimum: £"+tmn+"\nRejected session bankroll: £"+rejected+"\nFrozen wagers at that bankroll: "+wagers+"\n\nThose wagers cannot all be placed exactly in £"+tmn+" increments."+suggestion+"\nChoose a compatible session bankroll: £";
        }
        if (low.contains("bankroll you want to use for this session")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            flushOperatorTranscript = true;
            return liveBreadcrumb + "\n\nWAGER SETUP\nPlatform balance: £"+livePlatformBalance+"\n\nBankroll you want to use for this session: £";
        }
        if (low.contains("declared experimental bankroll represented")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            return shown.replace("Declared experimental bankroll represented by the £100 Frozen research frame: £", "Bankroll you want to use for this session: £");
        }
        if ((low.contains("research bankroll:") || (low.contains("session bankroll:") && low.contains("reference wagers:")))
                && (low.contains("smallest wager/denomination") || low.contains("smallest wager available on this table"))) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP";
            Matcher f=Pattern.compile("(?:Frozen|Reference wagers):\s*£?([0-9.]+)\s*/\s*£?([0-9.]+)\s*/\s*£?([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String fw=f.find()?"£"+f.group(1)+" / £"+f.group(2)+" / £"+f.group(3):"—";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nWAGER SETUP\nSession bankroll: £"+liveSessionBankroll+"\nFrozen wagers: "+fw+"\n\nSmallest wager available on this table: £";
        }
        if (low.contains("frozen reference: ineligible")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → WAGER SETUP → WAGER FLOOR";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nFROZEN REFERENCE: INELIGIBLE\nFrozen comparison is unavailable at this table minimum.\nPersonal mode can continue.";
        }
        if (low.contains("have all wagers/chips been cleared from the table?") || low.contains("are all wagers/chips cleared from the table?")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → STARTING BALANCE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nSTARTING BALANCE\nSession bankroll: £"+liveSessionBankroll+(liveMode.equals("FROZEN")?"\nFrozen wagers: "+frozenWagersForBankroll(liveSessionBankroll):"")+"\n\nAre all wagers/chips cleared from the table? [Y/N]:";
        }
        if (!livePreambleFlow && low.contains("enter your current platform balance")
                && !low.contains("balance locked") && !low.contains("starting platform balance must") && !low.contains("after hand")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → STARTING BALANCE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nSTARTING BALANCE\nSession bankroll: £"+liveSessionBankroll+"\n\nEnter your current platform balance: £";
        }
        if (low.contains("starting platform balance must cover") || low.contains("starting platform balance must be greater")) {
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → STARTING BALANCE";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nSTARTING BALANCE\nSession bankroll: £"+liveSessionBankroll+"\n\nYou entered: £"+(lastOperatorInput.isBlank()?"—":lastOperatorInput)+"\nBalance too low.\nMinimum required: £"+liveSessionBankroll+"\n\nEnter your current platform balance: £";
        }
        if ((low.contains("balance locked") || low.contains("session lock")) && low.contains("visible shuffle immediately before this hand")) {
            Matcher pb=Pattern.compile("(?:BALANCE LOCKED|SESSION LOCK)\\s*\\|.*?Platform\\s*£?([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String platform=pb.find()?pb.group(1):livePlatformBalance;
            livePlatformBalance=platform;
            Matcher hm=Pattern.compile("HAND\\s+(\\d+)\\s*/\\s*(\\d+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            boolean handFound=hm.find();
            String hand=handFound?hm.group(1):liveCurrentHand, total=handFound?hm.group(2):"30";
            if(hand==null || hand.isBlank()) hand="1";
            liveCurrentHand=hand;
            latchConcentrationForHand(hand);
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → HAND " + hand;
            flushOperatorTranscript=true;
            String frozen=low.contains("frozen n/a")?"N/A":"Available";
            return ""+liveBreadcrumb+"\n\nHAND "+hand+" OF "+total+"\nSession bankroll: £"+liveSessionBankroll+"\nPlatform balance: £"+platform+"\nFrozen comparison: "+frozen+"\n\nWas there a visible shuffle immediately before this hand? [Y/N/Q]:";
        }
        if (low.contains("visible shuffle immediately before this hand")) {
            Matcher hm=Pattern.compile("HAND\\s+(\\d+)\\s*/\\s*(\\d+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            boolean handFound=hm.find();
            String hand=handFound?hm.group(1):liveCurrentHand;
            if(hand==null || hand.isBlank()) hand="1";
            if(handFound) liveCurrentHand=hand;
            latchConcentrationForHand(hand);
            liveBreadcrumb = modeCrumb() + " → " + livePlatformCrumb() + " → HAND " + hand;
            if(low.contains("hand "+hand+" / 30") || low.contains("hand "+hand+"/30")) {
                flushOperatorTranscript=true;
                String platform=livePlatformBalance.equals("—")?lastOperatorInput:livePlatformBalance;
                if(platform==null||platform.isBlank()) platform="—";
                return ""+liveBreadcrumb+"\n\nHAND "+hand+" OF 30\nSession bankroll: £"+liveSessionBankroll+"\nPlatform balance: £"+platform+"\n\nWas there a visible shuffle immediately before this hand? [Y/N/Q]:";
            }
        }
        if (low.contains("before wagering, show previous hands played on this table/session")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand;
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSession bankroll: £"+liveSessionBankroll+"\n\nShow previous hands from this table before wagering? [Y/N]:";
        }
        if (low.contains("wager rejected") && low.contains("invalid at")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → WAGER";
            flushOperatorTranscript=true;
            Matcher a=Pattern.compile("£([0-9.]+).*?£([0-9.]+) increments",Pattern.CASE_INSENSITIVE).matcher(shown);
            boolean am=a.find(); String entered=am?a.group(1):"—", inc=am?a.group(2):"—";
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSession bankroll: £"+liveSessionBankroll+"\n\nWAGER\nYou entered: £"+entered+"\nInvalid wager. Use £"+inc+" increments.\n\nEnter your wager: £";
        }
        if (low.contains("wager rejected: research-equivalent bankroll is only")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → WAGER";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSession bankroll: £"+liveSessionBankroll+"\n\nWAGER\nYou entered: £"+(lastOperatorInput.isBlank()?"—":lastOperatorInput)+"\nWager exceeds your available session bankroll: £"+liveSessionBankroll+".\n\nEnter your wager: £";
        }
        if (low.contains("actual scaled wager") || low.contains("actual wager")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → WAGER";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSession bankroll: £"+liveSessionBankroll+"\n\nWAGER\nEnter your wager: £";
        }
        if (low.contains("show stat-watching casual's wager") || (low.contains("personal wager committed") && low.contains("stat-watching casual"))) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → WAGER";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSession bankroll: £"+liveSessionBankroll+"\nWager: £"+(lastOperatorInput.isBlank()?"—":lastOperatorInput)+"\n\nShow Stat-Watching Casual's wager? [Y/N]:";
        }
        if (low.contains("stat-watching casual wager:")) {
            Matcher sw=Pattern.compile("WAGER:\s*£?([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String v=sw.find()?sw.group(1):"—";
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → STAT-WATCHING";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSTAT-WATCHING CASUAL\nComparison wager: £"+v+" equivalent — not playable at this table\nYour wager: £"+liveSessionBankroll+"\n\nShow mindset? [Y/N]:";
        }
        if (low.contains("stat-watching casual mindset:")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → INITIAL DEAL";
            flushOperatorTranscript=true;
            Matcher mm=Pattern.compile("MINDSET:\s*(.*?)(?:\r?\n|Initial visible deal entry)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String mindset=mm.find()?mm.group(1).trim():"Comparison mindset recorded.";
            String guide=liveCurrentHand.equals("1")?"Deal order: Player → Dealer → Player\n":"";
            mindset=mindset.replace("Too little recent completed-hand evidence for a press; ordinary", "Not enough recent evidence to press — ordinary");
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSTAT-WATCHING CASUAL\nMindset: "+mindset+"\n\nINITIAL DEAL\n"+guide+"Player card 1:";
        }
        if (low.contains("main ") && low.contains("personal decision") && low.contains("actual action")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → DECISION";
            flushOperatorTranscript=true;
            Matcher ph=Pattern.compile("MAIN\s+[^=]+=\s*([0-9]+)",Pattern.CASE_INSENSITIVE).matcher(shown);
            String total=ph.find()?ph.group(1):"";
            String dbl=low.contains("double unavailable")?"Double: Unavailable — insufficient bankroll\n":"";
            String avail=low.contains("available: hs")?"[H] Hit   [S] Stand":"[H] Hit   [S] Stand   [D] Double   [P] Split";
            int handTotal=displayHandTotal(liveP1,liveP2);
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nYour hand: "+prettyCard(liveP1)+" "+prettyCard(liveP2)+" = "+handTotal+"\nDealer showing: "+prettyCard(liveUp)+"\n\n"+dbl+"Your action: "+avail;
        }
        if (low.contains("dealer hidden/draw sequence in order")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → DEALER";
            flushOperatorTranscript=true;
            if(splitActive){
                return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nSplit round in progress — SPLIT A / SPLIT B shown above\nDealer: "+prettyCard(liveUp)+"\n\nEnter dealer hidden/draw cards:  Example: 10D,5C";
            }
            java.util.List<String> dealerViewPlayerCards=liveOrdinaryPlayerCards();
            int handTotal=displayHandTotal(dealerViewPlayerCards.toArray(new String[0]));
            String playerCards=prettyCards(dealerViewPlayerCards);
            String finalAction=handTotal>21?"BUST":(liveDoubleCard.isBlank()?"STAND":"DOUBLE");
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30\nYou: "+playerCards+" = "+handTotal+" — "+finalAction+"\nDealer: "+prettyCard(liveUp)+"\n\nEnter dealer hidden/draw cards:  Example: 10D,5C";
        }
        if (low.contains("does this reconstructed card order correctly represent the hand")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → CARD CHECK";
            flushOperatorTranscript=true;
            java.util.List<String> order=new ArrayList<>();
            Matcher cm=Pattern.compile("(?m)^#\\d+\\s*\\|\\s*H\\d+\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([0-9AJQK]+[SHDC])\\s*$",Pattern.CASE_INSENSITIVE).matcher(shown);
            while(cm.find()){
                String role=cm.group(1).trim();
                String upper=role.toUpperCase(Locale.ROOT);
                String label;
                if(upper.equals("PLAYER CARD 1") || upper.equals("PLAYER CARD 2")) label="Player";
                else if(upper.equals("DEALER UPCARD")) label="Dealer upcard";
                else if(upper.equals("DEALER HOLE CARD")) label="Dealer (hole)";
                else if(upper.startsWith("DEALER DRAW")) label=role;
                else if(upper.contains("HIT CARD")) label="Player HIT";
                else if(upper.contains("DOUBLE CARD")) label="Player DOUBLE";
                else if(upper.startsWith("SPLIT A") || upper.startsWith("SPLIT B")) label=role;
                else if(upper.startsWith("PLAYER POST-DEAL") || upper.startsWith("SOURCE-ONLY")) label=role;
                else continue;
                order.add(label+" — "+prettyCard(cm.group(2)));
            }
            // v15.10.163-UAT: Card Check must never depend on the backend transcript
            // renderer retaining its reconstructed-order rows.  The Live GUI already owns
            // the authoritative entered state for P1, dealer upcard, P2 and dealer sequence.
            // Use that state whenever the parsed backend order is absent/incomplete.
            // v15.10.346-UAT: split chronology remains available at CARD CHECK even after
            // splitActive has been cleared by the dealer/result transition.  The retained
            // branch lists are the authoritative presentation state; gating on splitActive
            // caused completed split hands to collapse back to the five-card base/dealer view.
            boolean splitChronologyAvailable = liveSplitHand == liveCurrentHand
                    && !liveSplitACards.isEmpty() && !liveSplitBCards.isEmpty();
            int splitExtraCount = splitChronologyAvailable
                    ? Math.max(0, liveSplitACards.size()-1) + Math.max(0, liveSplitBCards.size()-1)
                    : 0;
            int splitDoubleCount = (splitChronologyAvailable && !liveSplitADoubleCard.isBlank() ? 1 : 0)
                    + (splitChronologyAvailable && !liveSplitBDoubleCard.isBlank() ? 1 : 0);
            int ordinaryPostDealCount = liveHitCards.size() + (liveDoubleCard.isBlank() ? 0 : 1);
            int expectedOrderSize = splitChronologyAvailable
                    ? 4 + splitExtraCount + splitDoubleCount + Math.max(0, (liveDealerSeq.isBlank() ? 0 : liveDealerSeq.split(",").length)-1)
                    : 3 + ordinaryPostDealCount + (liveDealerSeq.isBlank() ? 0 : liveDealerSeq.split(",").length);
            if(order.size() < expectedOrderSize) {
                order.clear();
                if(!liveP1.isBlank()) order.add("Player — "+prettyCard(liveP1));
                String[] ds=liveDealerSeq.isBlank() ? new String[0] : liveDealerSeq.split(",");
                if(paddyPowerLiveSource()) {
                    if(!liveUp.isBlank()) order.add("Dealer upcard — "+prettyCard(liveUp));
                    if(!liveP2.isBlank()) order.add("Player — "+prettyCard(liveP2));
                    if(ds.length>0) order.add("Dealer (hole) — "+prettyCard(ds[0]));
                } else {
                    // 247 established physical chronology: P1 -> dealer hole -> P2 -> dealer upcard.
                    if(ds.length>0) order.add("Dealer (hole) — "+prettyCard(ds[0]));
                    if(!liveP2.isBlank()) order.add("Player — "+prettyCard(liveP2));
                    if(!liveUp.isBlank()) order.add("Dealer upcard — "+prettyCard(liveUp));
                }
                if(splitChronologyAvailable) {
                    // Split physical chronology: branch cards are entered/played A then B;
                    // the double card is the terminal card for that branch and is therefore
                    // placed after the non-double additions for both branches.
                    for(int i=1;i<liveSplitACards.size();i++) {
                        String c=liveSplitACards.get(i);
                        if(!c.equalsIgnoreCase(liveSplitADoubleCard)) order.add("Split A added card — "+prettyCard(c));
                    }
                    for(int i=1;i<liveSplitBCards.size();i++) {
                        String c=liveSplitBCards.get(i);
                        if(!c.equalsIgnoreCase(liveSplitBDoubleCard)) order.add("Split B added card — "+prettyCard(c));
                    }
                    if(!liveSplitADoubleCard.isBlank()) order.add("Split A DOUBLE — "+prettyCard(liveSplitADoubleCard));
                    if(!liveSplitBDoubleCard.isBlank()) order.add("Split B DOUBLE — "+prettyCard(liveSplitBDoubleCard));
                } else {
                    for(String c:liveHitCards) order.add("Player HIT — "+prettyCard(c));
                    if(!liveDoubleCard.isBlank()) order.add("Player DOUBLE — "+prettyCard(liveDoubleCard));
                }
                for(int i=1;i<ds.length;i++)
                    order.add("Dealer draw "+i+" — "+prettyCard(ds[i]));
            }
            StringBuilder b=new StringBuilder(liveBreadcrumb).append("\n\nHAND ").append(liveCurrentHand).append(" — CARD CHECK\n\nCard order\n");
            for(int i=0;i<order.size();i++) b.append(i+1).append(". ").append(order.get(i)).append("\n");
            b.append("\nIs this card order correct? [Y/N]:");
            return b.toString();
        }
        if (low.contains("result w/l/p")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → RESULT";
            flushOperatorTranscript=true;
            String dealerCards=prettyCard(liveUp);
            if(!liveDealerSeq.isBlank()) for(String c:liveDealerSeq.split(",")) dealerCards+=" "+prettyCard(c);
            java.util.List<String> dc=new ArrayList<>(); dc.add(liveUp); if(!liveDealerSeq.isBlank()) dc.addAll(Arrays.asList(liveDealerSeq.split(",")));
            int dt=displayHandTotal(dc.toArray(new String[0]));
            String playerLine;
            if(splitActive && !liveSplitACards.isEmpty() && !liveSplitBCards.isEmpty()) {
                int at=displayHandTotal(liveSplitACards.toArray(new String[0]));
                int bt=displayHandTotal(liveSplitBCards.toArray(new String[0]));
                playerLine="Split A: "+prettyCards(liveSplitACards)+" = "+at+"\nSplit B: "+prettyCards(liveSplitBCards)+" = "+bt;
            } else {
                java.util.List<String> pc=liveOrdinaryPlayerCards();
                int pt=displayHandTotal(pc.toArray(new String[0]));
                playerLine="Your hand: "+prettyCards(pc)+" = "+pt;
            }
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" OF 30 — RESULT\n"+playerLine+"\nDealer: "+dealerCards+" = "+dt+"\n\n[W] Win   [L] Loss   [P] Push\nChoose result:";
        }

        if (low.contains("result validated") && low.contains("enter the platform balance shown after the result")) {
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → RESULT";
            flushOperatorTranscript=true;
            String dealerCards=prettyCard(liveUp);
            java.util.List<String> dc=new ArrayList<>(); dc.add(liveUp);
            if(!liveDealerSeq.isBlank()) for(String c:liveDealerSeq.split(",")){ dealerCards+=" "+prettyCard(c); dc.add(c); }
            java.util.List<String> pc=liveOrdinaryPlayerCards();
            int pt=displayHandTotal(pc.toArray(new String[0])), dt=displayHandTotal(dc.toArray(new String[0]));
            String res=liveLastResult.equals("L")?"LOSS":liveLastResult.equals("W")?"WIN":liveLastResult.equals("P")?"PUSH":"RESULT";
            return liveBreadcrumb+"\n\nRESULT — "+res+"\nYour hand: "+prettyCards(pc)+" = "+pt+"\nDealer: "+dealerCards+" = "+dt+"\n\nPlatform balance after this hand: £";
        }

        if (paddyPowerLive && !liveCurrentHand.isBlank() && !liveLastResult.isBlank() &&
            low.matches("(?s).*hand\\s+"+liveCurrentHand+"\\s+captured\\.\\s*amend.*")) {
            // v15.10.187-UAT: the captured/amend prompt itself is the universal formal-hand
            // presentation boundary.  Do not depend on BALANCE OK / BALANCE VALIDATED /
            // SETTLEMENT COMMITTED being coalesced into the same Swing append: ordinary
            // Double and split settlement can arrive with different append grouping.
            // Financial authority remains SETTLEMENT COMMITTED; this block is display-only.
            liveBreadcrumb=modeCrumb()+" → "+livePlatformCrumb()+" → HAND "+liveCurrentHand+" → COMPLETE";
            flushOperatorTranscript=true;
            String res=liveLastResult.equals("L")?"LOSS":liveLastResult.equals("W")?"WIN":liveLastResult.equals("P")?"PUSH":liveLastResult;
            String dealerCards=prettyCard(liveUp); java.util.List<String> dc=new ArrayList<>(); dc.add(liveUp);
            if(!liveDealerSeq.isBlank()) for(String c:liveDealerSeq.split(",")){ dealerCards+=" "+prettyCard(c); dc.add(c); }
            java.util.List<String> pc=liveOrdinaryPlayerCards();
            String playerCards=prettyCards(pc);
            int pt=displayHandTotal(pc.toArray(new String[0]));
            // Split branches have their own authoritative GUI panels/evidence. Never collapse
            // them into a fictitious single player total in the concise completion banner.
            if(splitActive && !liveSplitACards.isEmpty() && !liveSplitBCards.isEmpty()) {
                int at=displayHandTotal(liveSplitACards.toArray(new String[0]));
                int bt=displayHandTotal(liveSplitBCards.toArray(new String[0]));
                playerCards="Split A: "+prettyCards(liveSplitACards)+" = "+at+"\nSplit B: "+prettyCards(liveSplitBCards)+" = "+bt; pt=-1;
            } else if(splitActive) { playerCards="SPLIT A / SPLIT B — see branch panels"; pt=-1; }
            int dt=displayHandTotal(dc.toArray(new String[0]));
            return ""+liveBreadcrumb+"\n\nHAND "+liveCurrentHand+" COMPLETE — "+res+"\nYour hand: "+playerCards+(pt<0?"":" = "+pt)+"\nDealer: "+dealerCards+" = "+dt+"\n\nAmend any details? [Y/N]:";
        }
        if (low.contains("live session ended automatically") && low.contains("no further wager")) {
            liveBreadcrumb="MAIN MENU → LIVE SESSION → SESSION ENDED";
            flushOperatorTranscript=true;
            return ""+liveBreadcrumb+"\n\nSESSION COMPLETE\nSession ended after Hand "+liveCurrentHand+" — no bankroll remaining.\n\nFinal session bankroll: £0.00\nSession evidence saved.\n\nReturning to Main Menu.";
        }
        shown = shown.replace("Record/use a PREAMBLE before the next formal session? [Y/N]:",
                "PREAMBLE\nUse a preamble before this formal session? [Y/N]:");
        return suppressConcentrationOutput(shown);
    }

    @Override
    void appendBackend(String text) {
        // v15.10.228-UAT: capture formal-hand identity and current session bankroll
        // before formatting; backend chunks can split the banner from later prompts.
        boolean hardFormalHandBoundary=false;
        if(text!=null){
            Matcher fh=Pattern.compile("=+\\s*HAND\\s+(\\d+)\\s*/\\s*30\\s*=+", Pattern.CASE_INSENSITIVE).matcher(text);
            while(fh.find()){
                liveCurrentHand=fh.group(1);
                // v15.10.232-UAT: DO NOT pre-write inherited currentHand here.
                // BlackjackLiveGuiOld.appendBackend() owns the H(n)->H(n+1) transition
                // and uses it to commit currentPhysicalCards() into previousHandPhysical.
                // Pre-advancing currentHand here suppressed that inherited commit and
                // left PREVIOUS HAND as "none yet". liveCurrentHand is presentation-only.
                hardFormalHandBoundary=true;
                final int newHand=Integer.parseInt(liveCurrentHand);
                SwingUtilities.invokeLater(()->{ setFormalLiveHeaderMode(true); clearControllerLifecycleRailForHand(newHand); });
            }
            Matcher sl=Pattern.compile("SESSION LOCK[^\\r\\n]*?\\|\\s*Session\\s*£?([0-9.]+)", Pattern.CASE_INSENSITIVE).matcher(text);
            while(sl.find()) liveSessionBankroll=sl.group(1);
            Matcher sb=Pattern.compile("Session bankroll\\s*£?([0-9.]+)", Pattern.CASE_INSENSITIVE).matcher(text);
            while(sb.find()) liveSessionBankroll=sb.group(1);
        }
        // v15.10.221-UAT: PREAMBLE HAND N is an authoritative live hand identity too.
        // The ordinary-session parser keys on HAND N / 30, so without this bridge a
        // preamble could visually and analytically remain latched to Hand 1.
        if(text!=null){
            Matcher ph=Pattern.compile("PREAMBLE HAND\\s+(\\d+)", Pattern.CASE_INSENSITIVE).matcher(text);
            while(ph.find()){
                liveCurrentHand=ph.group(1);
                try { currentHand=Integer.parseInt(liveCurrentHand); } catch(Exception ignored) {}
            }
        }
        // v15.10.175-UAT: a completed split can reach the post-hand/next-hand prompt
        // without the inherited transcript parser seeing a standalone RESULT VALIDATED
        // line in the same append. Commit the operator-entered result to Recent Player
        // Experience at this authoritative hand boundary. captureRecentPlayerExperience()
        // below is duplicate-guarded, so the inherited commit path remains safe.
        if(text!=null){
            String boundary=text.toLowerCase(Locale.ROOT);
            if((boundary.contains("enter next hand, or q to finish") ||
                boundary.contains("enter next preamble hand, or q to end preamble")) &&
               currentHand>0 && !liveLastResult.isBlank()) {
                ensureExplicitJourneyForCurrentHand();
            }
        }
        // v15.10.191-UAT: capture only the two resolved child outcomes from the
        // authoritative split balance/settlement line.  No extra figures are shown.
        if(text!=null) {
            Matcher splitOutcome=Pattern.compile("SPLIT BALANCE CHECK\\s*\\|\\s*A\\s+[£?]?[^WLP\\r\\n|]*?\\b([WLP])\\b[^|]*\\|\\s*B\\s+[£?]?[^WLP\\r\\n|]*?\\b([WLP])\\b", Pattern.CASE_INSENSITIVE).matcher(text);
            if(splitOutcome.find()) {
                liveSplitAOutcome=splitOutcome.group(1).toUpperCase(Locale.ROOT);
                liveSplitBOutcome=splitOutcome.group(2).toUpperCase(Locale.ROOT);
                SwingUtilities.invokeLater(this::refreshCurrentTable);
            }
        }
        // v15.10.238-UAT: authoritative dealer presentation commit. The CLI reaches
        // CARD STREAM REVIEW only after dealerSequenceCompleteForLiveSettlement() has
        // accepted the complete batch. Until here, rejected/incomplete entries must
        // not alter the table or dealer-hole memory.
        if(text!=null && text.toLowerCase(Locale.ROOT).contains("does this reconstructed card order correctly represent the hand?")
                && pendingDealerSequence!=null && !pendingDealerSequence.isBlank()) {
            liveDealerSeq=pendingDealerSequence;
            pendingDealerSequence="";
            SwingUtilities.invokeLater(() -> { refreshCurrentTable(); refreshCards(); refreshVisibleFrequencyCounts(); });
        }
        updateLiveV2ObservationFromBackend(text);
        updateControllerLifecycleRailFromBackend(text);
        // v15.10.229-UAT: HAND N COMPLETE is an authoritative presentation boundary.
        // Restore PREVIOUS HAND immediately from the still-live card state.  This is
        // display-only and deliberately does not call commitCurrentHand().
        if(text!=null && Pattern.compile("HAND\\s+\\d+\\s+COMPLETE",Pattern.CASE_INSENSITIVE).matcher(text).find())
            SwingUtilities.invokeLater(this::publishCompletedHandToPreviousPanel);
        // v15.10.223-UAT: honour the CLI's explicit ANSI clear-screen boundary in the GUI.
        // This keeps lifecycle transitions (for example Pending Preamble KEEP -> Main Menu)
        // visually clean without changing or deleting authoritative transcript/evidence files.
        boolean hardAnsiClearBoundary = text!=null && (text.contains("\u001B[3J") || text.contains("\u001B[2J"));
        if(hardAnsiClearBoundary)
            flushOperatorTranscript=true;
        // v15.10.229-UAT / CLI V9.2.248: LIVE PLAY is also a semantic operator-screen
        // boundary.  Some backend/GUI stream chunking can separate or consume the ANSI
        // clear sequence before the following Live text is formatted.  Treat the explicit
        // Live banner as a second, idempotent flush trigger so startup/Main Menu text can
        // never remain above a newly entered Live workflow.
        if(text!=null && text.contains("*** LIVE PLAY ***"))
            flushOperatorTranscript=true;
        // v15.10.235-UAT: every PREAMBLE HAND banner is an operator-screen boundary.
        // Flush setup/previous-hand transcript before rendering the new hand while
        // preserving evidence/history state.
        if(text!=null && Pattern.compile("=+\\s*PREAMBLE HAND\\s+\\d+\\s*=+",Pattern.CASE_INSENSITIVE).matcher(text).find())
            flushOperatorTranscript=true;
        // v15.10.225-UAT: formal hand banners are also hard operator-screen boundaries.
        if(text!=null && Pattern.compile("=+\\s*HAND\\s+\\d+\\s*/\\s*30\\s*=+",Pattern.CASE_INSENSITIVE).matcher(text).find())
            flushOperatorTranscript=true;
        int visualStart = transcript.getDocument().getLength();
        super.appendBackend(text);
        int visualEnd = transcript.getDocument().getLength();
        try {
            String formatted = formatOperatorTranscript(text);
            // formatOperatorTranscript resets its local flush flag. Retain raw hard
            // boundaries after formatting so an ANSI-only clear chunk cannot be lost
            // before a Live blocker/publication gate arrives in the next backend chunk.
            if(hardAnsiClearBoundary || hardFormalHandBoundary) flushOperatorTranscript=true;
            if (formatted != null && visualEnd >= visualStart) {
                if (flushOperatorTranscript) transcript.setText(formatted);
                else if (!formatted.equals(text)) transcript.replaceRange(formatted, visualStart, visualEnd);
                transcript.setCaretPosition(transcript.getDocument().getLength());
            }
        } catch (Exception ignored) {}
        sanitizeQuickInputForCardReview(text);
        autoAnswerSuppressedPrompts(text);
        updateDeclaredScaleFromBackend(text);
        updateScaledPreambleBankrollBadges(text);
        if(text!=null){
            String platformLine=text.toLowerCase(Locale.ROOT);
            if(platformLine.contains("platform declared: paddy power") || platformLine.contains("source: paddy power")){
                paddyPowerLive=true;
                updateLiveSourceHeading();
            } else if(platformLine.contains("platform declared: 247blackjack")){
                paddyPowerLive=false;
                updateLiveSourceHeading();
            }
        }
        // The backend is authoritative. When Q reaches its terminal state, the live
        // companion must not remain as a stale, apparently interactive window.
        if(text!=null && text.toLowerCase(Locale.ROOT).contains("program closed.")){
            terminalInput.setText("");
            SwingUtilities.invokeLater(() -> {
                setEnabled(false);
                dispose();
                // Pre-Phase-6 shutdown repair: Q closes the authoritative CLI first.
                // A disposed Swing window alone does not terminate this standalone
                // javaw JVM because AWT/shutdown infrastructure can remain alive.
                // Exit this DBJ-owned GUI JVM explicitly; its shutdown hook only
                // cleans up the backend Process spawned by this window.
                System.exit(0);
            });
            return;
        }

        // v15.10.229-UAT / V9.2.246: every authoritative FORMAL hand banner is also
        // a hard transient-card boundary.  The inherited GUI can retain the prior
        // dealer hole/draw staging even though the CLI has advanced to the new hand.
        // Clear ONLY current-hand visual/input staging here; historical analysis and
        // dealer-memory evidence remain intact.  This prevents H(n-1) dealer cards
        // appearing beside H(n)'s upcard before the new dealer sequence is entered.
        Matcher formalCardBoundary=Pattern.compile("=+\\s*HAND\\s+(\\d+)\\s*/\\s*30\\s*=+",Pattern.CASE_INSENSITIVE).matcher(text==null?"":text);
        if(formalCardBoundary.find()){
            clearCurrentHand(false);
            liveP1=""; liveUp=""; liveP2=""; liveDoubleCard=""; liveDealerSeq=""; pendingDealerSequence="";
            liveHitCards.clear();
            lastDealerSequenceTyped="";
            lastAwaitingCardRole=null;
            terminalInput.setText("");
            refreshCurrentTable();
            // v15.10.231-UAT: clearCurrentHand(false) runs after the completed-hand
            // publication and can overwrite PREVIOUS HAND from already-cleared live
            // staging. Restore the captured completed-hand snapshot only after this
            // authoritative next-hand reset. Presentation-only: no settlement,
            // evidence, controller, bankroll or Frozen state is changed.
            if(currentHand > 1 && !completedHandPresentationSnapshot.isEmpty())
                publishCompletedHandToPreviousPanel();
            refreshCards();
            refreshVisibleFrequencyCounts();
        }

        // Live/preamble hand-boundary reset.  The inherited parser can begin a
        // PREAMBLE hand without advancing its formal-hand counter, so explicitly
        // clear the main-table staging as soon as the authoritative backend emits
        // the next PREAMBLE HAND banner.  History/memory panels remain untouched.
        Matcher preambleBoundary=Pattern.compile("=+\\s*PREAMBLE HAND\\s+(\\d+)\\s*=+",Pattern.CASE_INSENSITIVE).matcher(text==null?"":text);
        if(preambleBoundary.find()){
            int h=Integer.parseInt(preambleBoundary.group(1));
            if(h>1){
                clearCurrentHand(false);
                lastDealerSequenceTyped="";
                pendingDealerSequence="";
                lastAwaitingCardRole=null;
                currentWagerText="?";
                currentBaseWagerValue=Double.NaN;
                currentCommittedWagerValue=Double.NaN;
                actualWagerEnteredThisHand=false;
                currentHybridDeviation=false;
                wagerBadge.setText("CURRENT WAGER: ?");
                terminalInput.setText("");
                refreshCurrentTable();
                refreshCards();
                refreshVisibleFrequencyCounts();
            }
        }

        // v15.10.64: a rejected card-stream review restarts the SAME hand number.
        // The inherited renderer normally clears on a hand-number change, so an
        // explicit same-hand rejection must clear the staged visual cards itself.
        String lower = text == null ? "" : text.toLowerCase(Locale.ROOT);
        if (lower.contains("card-stream review:") &&
            lower.contains("rejected before result/bankroll entry") &&
            lower.contains("re-entering the hand")) {
            clearCurrentHand(false);
            currentWagerText = "?";
            currentBaseWagerValue = Double.NaN;
            currentCommittedWagerValue = Double.NaN;
            actualWagerEnteredThisHand = false;
            currentHybridDeviation = false;
            wagerBadge.setText("CURRENT WAGER: ?");
            lastDealerSequenceTyped = "";
            pendingDealerSequence = "";
            lastAwaitingCardRole = null;
            terminalInput.setText("");
            refreshCurrentTable();
            refreshCards();
            refreshVisibleFrequencyCounts();
            terminalInput.requestFocusInWindow();
        }

        // v15.10.137: a post-settlement AMENDMENT also restarts the SAME hand.
        // Treat it exactly as a rejected card-stream review at the GUI boundary:
        // discard every staged card/input item from the superseded attempt before
        // the replacement hand begins.  The authoritative CLI already rolls back
        // its current-hand counters/cards/exposure; this keeps the transient GUI
        // state in lock-step with that rollback.
        if (lower.contains("amendment: hand") &&
            lower.contains("superseded before commit") &&
            lower.contains("re-entered from the beginning")) {
            clearCurrentHand(false);
            currentWagerText = "?";
            currentBaseWagerValue = Double.NaN;
            currentCommittedWagerValue = Double.NaN;
            actualWagerEnteredThisHand = false;
            currentHybridDeviation = false;
            wagerBadge.setText("CURRENT WAGER: ?");
            lastDealerSequenceTyped = "";
            pendingDealerSequence = "";
            lastAwaitingCardRole = null;
            terminalInput.setText("");
            refreshCurrentTable();
            refreshCards();
            refreshVisibleFrequencyCounts();
            terminalInput.requestFocusInWindow();
        }

        // The inherited helper can repopulate the just-entered dealer sequence while
        // the backend is moving from that prompt to CARD STREAM REVIEW / Y-N.
        // Once the active prompt is no longer the dealer-sequence prompt, QUICK INPUT
        // must be empty so an accepted card cannot accidentally be re-submitted.
        String p = currentPrompt();
        String pl = p == null ? "" : p.toLowerCase(Locale.ROOT);
        if (!pl.contains("dealer hidden/draw sequence") &&
            lastDealerSequenceTyped != null &&
            !lastDealerSequenceTyped.isBlank() &&
            terminalInput.getText().trim().equalsIgnoreCase(lastDealerSequenceTyped.trim())) {
            terminalInput.setText("");
        }
    }

    // v15.10.139: presentation-only semantic identity marker for the human-controlled live player.
    private void installLiveHumanIdentity(){
        liveHumanIdentity.setToolTipText("Human-controlled live player — same identity used in Replay and publication plates");
        getLayeredPane().add(liveHumanIdentity,JLayeredPane.PALETTE_LAYER);
        repositionLiveHumanIdentity();
    }
    private void repositionLiveHumanIdentity(){
        // v15.10.223: presentation-only thumbnail placement. Keep the established
        // human-controller identity larger and place it below the version/title end,
        // immediately before the Frozen Wager Scale panel. Header height is unchanged.
        int w=48,h=48;int statsW=132;int right=24+statsW+12;int percentileW=190,scaleW=180,gap=8;
        int scaleX=Math.max(560,getLayeredPane().getWidth()-right-percentileW-gap-scaleW);
        int x=Math.max(500,scaleX-w-10);int y=34;
        liveHumanIdentity.setBounds(x,y,w,h);liveHumanIdentity.revalidate();liveHumanIdentity.repaint();
    }

    private void installStatsCornerButton() {
        statsCornerButton.setFont(new Font("SansSerif", Font.BOLD, 11));
        statsCornerButton.setForeground(new Color(10, 41, 75));
        statsCornerButton.setBackground(new Color(245, 248, 251));
        statsCornerButton.setFocusPainted(false);
        statsCornerButton.setToolTipText("Open Fascinating Stats Corner without closing the live companion");
        statsCornerButton.addActionListener(e -> BlackjackSessionReplay.openStatsCornerFromLive(this));

        JLayeredPane layered = getLayeredPane();
        layered.add(statsCornerButton, JLayeredPane.PALETTE_LAYER);
        repositionStatsButton();
        addComponentListener(new ComponentAdapter() {
            @Override public void componentResized(ComponentEvent e) { repositionStatsButton(); }
            @Override public void componentShown(ComponentEvent e) { repositionStatsButton(); }
        });
    }

    private void repositionStatsButton() {
        int w = 132;
        int h = 30;
        int x = Math.max(20, getLayeredPane().getWidth() - w - 24);
        int y = 14;
        statsCornerButton.setBounds(x, y, w, h);
        statsCornerButton.revalidate();
        statsCornerButton.repaint();
        repositionPercentileWagerPanel();
        repositionScaledWagerPanel();
        repositionLiveHumanIdentity();
        repositionLiveCardRatePanel();
        repositionControllerLifecycleRail();
    }

    // Release presentation addition: use the previously blank top-right header space
    // to state exactly which ordinary wager denominations are percentile-score eligible.
    // This is explanatory UI only; it does not alter wagering, scoring, evidence or play logic.
    private void installPercentileWagerPanel() {
        percentileWagerPanel.setOpaque(true);
        percentileWagerPanel.setBackground(new Color(20, 69, 111));
        percentileWagerPanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(205, 224, 238), 1),
                BorderFactory.createEmptyBorder(3, 8, 3, 8)));

        JLabel title = new JLabel("PERCENTILE-ELIGIBLE WAGERS", SwingConstants.CENTER);
        title.setFont(new Font("SansSerif", Font.BOLD, 10));
        title.setForeground(Color.WHITE);

        JLabel wagers = new JLabel("£15  •  £20  •  £30", SwingConstants.CENTER);
        wagers.setFont(new Font("SansSerif", Font.BOLD, 15));
        wagers.setForeground(new Color(255, 220, 92));

        JLabel note = new JLabel("Other denominations do not qualify for percentile scoring.", SwingConstants.CENTER);
        note.setFont(new Font("SansSerif", Font.PLAIN, 8));
        note.setForeground(new Color(238, 244, 249));

        JPanel middle = new JPanel(new BorderLayout());
        middle.setOpaque(false);
        middle.add(wagers, BorderLayout.CENTER);
        middle.add(note, BorderLayout.SOUTH);
        percentileWagerPanel.add(title, BorderLayout.NORTH);
        percentileWagerPanel.add(middle, BorderLayout.CENTER);

        getLayeredPane().add(percentileWagerPanel, JLayeredPane.PALETTE_LAYER);
        repositionPercentileWagerPanel();
    }

    private void repositionPercentileWagerPanel() {
        if (percentileWagerPanel == null) return;
        int statsW = 132;
        int right = 24 + statsW + 12;
        int w = 190;
        int h = 54;
        int x = Math.max(820, getLayeredPane().getWidth() - right - w);
        int y = 8;
        percentileWagerPanel.setBounds(x, y, w, h);
        percentileWagerPanel.revalidate();
        percentileWagerPanel.repaint();
    }

    private void installScaledWagerPanel() {
        scaledWagerPanel.setOpaque(true);
        scaledWagerPanel.setBackground(new Color(0, 82, 67));
        scaledWagerPanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(90, 220, 170), 1),
                BorderFactory.createEmptyBorder(2, 7, 2, 7)));
        JLabel title = new JLabel("FROZEN WAGER SCALE", SwingConstants.CENTER);
        title.setFont(new Font("SansSerif", Font.BOLD, 9));
        title.setForeground(Color.WHITE);
        scaledWagerNote.setFont(new Font("SansSerif", Font.PLAIN, 8));
        scaledWagerNote.setForeground(new Color(238, 248, 244));
        scaledWagerValues.setFont(new Font("SansSerif", Font.BOLD, 12));
        scaledWagerValues.setForeground(new Color(255, 220, 92));
        scaledWagerEquivalent.setFont(new Font("SansSerif", Font.PLAIN, 8));
        scaledWagerEquivalent.setForeground(new Color(238, 248, 244));
        scaledWagerPanel.setLayout(new GridLayout(4,1,0,0));
        scaledWagerPanel.add(title); scaledWagerPanel.add(scaledWagerNote);
        scaledWagerPanel.add(scaledWagerValues); scaledWagerPanel.add(scaledWagerEquivalent);
        getLayeredPane().add(scaledWagerPanel,JLayeredPane.PALETTE_LAYER);
        repositionScaledWagerPanel();
    }

    private void repositionScaledWagerPanel() {
        if(scaledWagerPanel==null)return;
        int h=54;
        if(formalLiveHandStarted){
            int railW=controllerLifecycleTiles.isEmpty()?0:Math.min(590,Math.max(200,controllerLifecycleTiles.size()*196));
            int gap=controllerLifecycleTiles.isEmpty()?0:8;
            int right=24+railW+gap;
            // v15.10.235-UAT: use the recovered header width as a large, legible
            // research reminder until controller STOP/EXIT tiles need the space.
            int w=controllerLifecycleTiles.isEmpty()?Math.min(760,Math.max(620,getLayeredPane().getWidth()-760)):340;
            int x=Math.max(720,getLayeredPane().getWidth()-right-w);
            scaledWagerPanel.setBounds(x,6,w,86);
        }else{
            int statsW=132, right=24+statsW+12, w=180;
            int percentileW=190, gap=8;
            int x=Math.max(560,getLayeredPane().getWidth()-right-percentileW-gap-w);
            scaledWagerPanel.setBounds(x,8,w,h);
        }
        scaledWagerPanel.revalidate(); scaledWagerPanel.repaint();
    }

    // Scaled real-money preamble uses an actual casino account balance as the
    // platform anchor, while the research journey always starts at £100.  The
    // legacy Live GUI superclass only recognises formal SESSION START lines, so
    // explicitly seed the two bankroll badges from the authoritative preamble
    // message without exposing the account-balance x scale as the research bank.
    private void installControllerLifecycleRail(){
        controllerLifecycleRail.setOpaque(false);
        controllerLifecycleRail.setVisible(false);
        getLayeredPane().add(controllerLifecycleRail,JLayeredPane.PALETTE_LAYER);
        repositionControllerLifecycleRail();
    }

    private void setFormalLiveHeaderMode(boolean started){
        formalLiveHandStarted=started;
        percentileWagerPanel.setVisible(!started);
        statsCornerButton.setVisible(!started);
        controllerLifecycleRail.setVisible(started && !controllerLifecycleTiles.isEmpty());
        // v15.10.235-UAT: Hand 1 is the operator's critical research-reminder screen.
        // Never surface a null bankroll in the Frozen Wager Scale; use the already
        // authoritative formal session bankroll captured by the live-entry path.
        if(started){
            String b=liveSessionBankroll==null?"":liveSessionBankroll.trim();
            if(!b.isEmpty() && !b.equalsIgnoreCase("null")){
                scaledWagerNote.setText("Bankroll £"+b+" • declared scale 1:10");
            } else {
                Matcher mb=Pattern.compile("([0-9]+(?:\\.[0-9]+)?)").matcher(currentBankrollText==null?"":currentBankrollText);
                if(mb.find()) scaledWagerNote.setText("Bankroll £"+mb.group(1)+" • declared scale 1:10");
            }
        }
        applyScaledWagerHeaderTypography();
        repositionScaledWagerPanel();
        repositionControllerLifecycleRail();
    }

    private void applyScaledWagerHeaderTypography(){
        Component[] cs=scaledWagerPanel.getComponents();
        if(cs.length>0 && cs[0] instanceof JLabel) ((JLabel)cs[0]).setFont(new Font("SansSerif",Font.BOLD,formalLiveHandStarted?17:9));
        scaledWagerNote.setFont(new Font("SansSerif",Font.PLAIN,formalLiveHandStarted?14:8));
        scaledWagerValues.setFont(new Font("SansSerif",Font.BOLD,formalLiveHandStarted?26:12));
        scaledWagerEquivalent.setFont(new Font("SansSerif",Font.PLAIN,formalLiveHandStarted?12:8));
    }

    private void clearControllerLifecycleRailForHand(int hand){
        lifecycleRailHand=hand;
        if(controllerLifecycleTiles.isEmpty()){
            controllerLifecycleRail.setVisible(false);
            repositionScaledWagerPanel();
            return;
        }
        // v15.10.237-UAT: fade completed-hand event tiles at the next-hand boundary.
        final java.util.List<JComponent> fading=new ArrayList<>(controllerLifecycleTiles);
        final int[] step={0};
        javax.swing.Timer fade=new javax.swing.Timer(40,null);
        fade.addActionListener(e->{
            step[0]++;
            float alpha=Math.max(0f,1f-(step[0]/8f));
            for(JComponent c:fading) if(c instanceof LifecycleTile) ((LifecycleTile)c).setTileAlpha(alpha);
            if(step[0]>=8){
                ((javax.swing.Timer)e.getSource()).stop();
                controllerLifecycleTiles.clear();
                controllerLifecycleRail.removeAll();
                controllerLifecycleRail.setVisible(false);
                controllerLifecycleRail.revalidate(); controllerLifecycleRail.repaint();
                repositionScaledWagerPanel();
            }
        });
        fade.start();
    }

    private ImageIcon lifecycleControllerImage(String controller){
        String c=controller==null?"":controller.toUpperCase(Locale.ROOT);
        String file=c.contains("STAT")?"stat_watching_casual.png":(c.contains("V2")?"ultimate_ai_v2.png":"ultimate_ai.png");
        try{
            Path p=appDir().resolve("assets").resolve("controller_events").resolve(file);
            if(!Files.exists(p)) return null;
            Image img=new ImageIcon(p.toString()).getImage().getScaledInstance(48,48,Image.SCALE_SMOOTH);
            return new ImageIcon(img);
        }catch(Exception ex){ return null; }
    }

    private JComponent lifecycleIdentity(String controller){
        JLabel icon=new JLabel("",SwingConstants.CENTER);
        icon.setPreferredSize(new Dimension(52,52));
        ImageIcon ii=lifecycleControllerImage(controller);
        if(ii!=null) icon.setIcon(ii);
        else { icon.setText(controller.toUpperCase(Locale.ROOT).contains("STAT")?"▥":"AI V2"); icon.setFont(new Font("SansSerif",Font.BOLD,11)); }
        icon.setToolTipText(controller);
        return icon;
    }

    private static class LifecycleTile extends JPanel{
        private float tileAlpha=1f;
        void setTileAlpha(float a){ tileAlpha=Math.max(0f,Math.min(1f,a)); repaint(); }
        @Override public void paint(Graphics g){
            Graphics2D g2=(Graphics2D)g.create();
            g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER,tileAlpha));
            super.paint(g2); g2.dispose();
        }
    }

    private static class LifecycleSign extends JComponent{
        final String word; final boolean stop;
        LifecycleSign(String word,boolean stop){this.word=word;this.stop=stop;setPreferredSize(new Dimension(58,52));setOpaque(false);}
        @Override protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create();
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth(),h=getHeight();
            if(stop){
                int m=4; int[] xs={m+10,w-m-10,w-m,w-m,w-m-10,m+10,m,m};
                int[] ys={m,m,m+10,h-m-10,h-m,h-m,m+h-10,m+10};
                g.setColor(new Color(211,35,35)); g.fillPolygon(xs,ys,8);
                g.setColor(Color.WHITE); g.setStroke(new BasicStroke(2f)); g.drawPolygon(xs,ys,8);
            }else{
                g.setColor(new Color(42,155,78)); g.fillRoundRect(3,4,w-6,h-8,8,8);
                g.setColor(Color.WHITE); g.setStroke(new BasicStroke(2f)); g.drawRoundRect(3,4,w-6,h-8,8,8);
            }
            Font f=new Font("SansSerif",Font.BOLD,stop?14:13); g.setFont(f); FontMetrics fm=g.getFontMetrics();
            g.setColor(Color.WHITE); g.drawString(word,(w-fm.stringWidth(word))/2,(h+fm.getAscent()-fm.getDescent())/2);
            g.dispose();
        }
    }

    private void addOrReplaceLifecycleTile(String key,String controller,String event,String amount,boolean stopStyle){
        for(int i=0;i<controllerLifecycleTiles.size();i++){
            JComponent c=controllerLifecycleTiles.get(i);
            if(key.equals(c.getName())){ controllerLifecycleRail.remove(c); controllerLifecycleTiles.remove(i); break; }
        }
        LifecycleTile tile=new LifecycleTile(); tile.setLayout(new FlowLayout(FlowLayout.LEFT,5,2)); tile.setName(key); tile.setOpaque(true);
        tile.setBackground(new Color(9,43,80));
        tile.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createLineBorder(new Color(218,187,36),2),BorderFactory.createEmptyBorder(1,3,1,4)));
        tile.add(new LifecycleSign(event,stopStyle));
        tile.add(lifecycleIdentity(controller));
        JPanel words=new JPanel(); words.setOpaque(false); words.setLayout(new BoxLayout(words,BoxLayout.Y_AXIS));
        String shortName=controller.toUpperCase(Locale.ROOT).contains("STAT")?"STAT WATCH":"AI V2";
        JLabel who=new JLabel(shortName); who.setFont(new Font("SansSerif",Font.BOLD,10)); who.setForeground(Color.WHITE);
        JLabel amt=new JLabel(amount==null||amount.isBlank()?"—":amount); amt.setFont(new Font("SansSerif",Font.BOLD,18)); amt.setForeground(new Color(255,220,65));
        words.add(who); words.add(amt); tile.add(words);
        tile.setPreferredSize(new Dimension(190,58));
        controllerLifecycleTiles.add(tile); controllerLifecycleRail.add(tile);
        controllerLifecycleRail.setVisible(formalLiveHandStarted);
        controllerLifecycleRail.revalidate(); controllerLifecycleRail.repaint();
        repositionScaledWagerPanel(); repositionControllerLifecycleRail();
    }

    private void updateControllerLifecycleRailFromBackend(String text){
        if(text==null||text.isBlank())return;
        // Retain the last genuine non-zero V2 shadow exposure. Post-exit NONE/£0
        // must not be presented as though £0 were the exposure being exited.
        Matcher exposure=Pattern.compile("EXISTING_EXPOSURE\\s+(?:BASE|MEDIUM|HIGH)\\s+£([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(exposure.find()){ try{ double v=Double.parseDouble(exposure.group(1)); if(v>0.0001) lastNonZeroV2Exposure=v; }catch(Exception ignored){} }
        Matcher sw=Pattern.compile("COMPARISON CONTROLLER EXIT\\s*\\|\\s*STAT-WATCHING CASUAL\\s*\\|\\s*after Hand\\s+(\\d+)[^\\r\\n]*?(?:bankroll|BANKROLL)\\s*£([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(sw.find()) addOrReplaceLifecycleTile("STAT_STOP","STAT-WATCHING","STOP","£"+sw.group(2),true);
        Matcher ex=Pattern.compile("ULTIMATE AI V2\\s*[—-]\\s*PHASE 6\\s*\\|\\s*EXIT LATCHED after Hand\\s+(\\d+)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(ex.find()){
            String amount=lastNonZeroV2Exposure>0.0001?"£"+String.format(Locale.ROOT,"%.0f",lastNonZeroV2Exposure):"—";
            addOrReplaceLifecycleTile("V2_EXIT","ULTIMATE AI V2","EXIT",amount,false);
        }
    }

    private void repositionControllerLifecycleRail(){
        if(controllerLifecycleRail==null)return;
        int count=controllerLifecycleTiles.size();
        int w=Math.min(590,Math.max(200,count*196)); int h=62;
        int x=Math.max(720,getLayeredPane().getWidth()-24-w);
        controllerLifecycleRail.setBounds(x,8,w,h);
        controllerLifecycleRail.revalidate(); controllerLifecycleRail.repaint();
    }

    private void updateScaledPreambleBankrollBadges(String text) {
        if(text==null)return;

        // v15.10.167-UAT: publish the formal session bankroll as soon as wager setup
        // makes it authoritative; do not wait until Hand 1 wager entry.
        Matcher setupBank=Pattern.compile("SESSION BANKROLL:\\s*using full confirmed platform balance\\s*[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(setupBank.find()){
            String b="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(setupBank.group(1)));
            setLegacyBankrollBadges(b,b);
            liveSessionBankroll=b.substring(1);
        }

        // Primary start declaration.  This is the player's deliberately allocated
        // session bankroll (e.g. £2.00), not the full casino account balance and
        // not the £100 research-normalisation frame.
        Matcher start=Pattern.compile("(?:PREAMBLE|SESSION) START BALANCE CONFIRMED:.*?declared session bankroll\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(start.find()){
            String b="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(start.group(1)));
            setLegacyBankrollBadges(b,b);
        }

        // The first pre-hand status line is an intentional second authority for
        // the badges.  It makes GUI startup robust even if the confirmation line
        // was emitted before this Swing reader attached to the backend stream.
        Matcher pre=Pattern.compile("Platform balance\\s+[^0-9-]*[0-9]+(?:\\.[0-9]+)?\\s*\\|\\s*Session bankroll\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(pre.find()){
            String b="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(pre.group(1)));
            if(legacyStartingBankrollUnset()) setLegacyBankrollBadges(b,b);
            else setLegacyCurrentBankrollBadge(b);
        }

        // v15.10.166-UAT: publish the authoritative hand-opening state immediately.
        Matcher handLock=Pattern.compile("SESSION LOCK\\s*\\|.*?Platform\\s*£?([0-9.]+).*?Session bankroll\\s*£?([0-9.]+).*?Starting bankroll\\s*£?([0-9.]+)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(handLock.find()){
            String platform="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(handLock.group(1)));
            String session="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(handLock.group(2)));
            String starting="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(handLock.group(3)));
            if(legacyStartingBankrollUnset()) setLegacyBankrollBadges(starting,session);
            else setLegacyCurrentBankrollBadge(session);
            livePlatformBalance=platform.substring(1);
            liveSessionBankroll=session.substring(1);
        }

        // v15.10.170-UAT: single validated balance line is now the operator-facing
        // post-settlement authority (the former pre-validation echo was removed).
        Matcher balanceOk=Pattern.compile("BALANCE OK\\s*\\|\\s*Platform\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)\\s*\\|\\s*Session bankroll\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(balanceOk.find()){
            livePlatformBalance=String.format(Locale.ROOT,"%.2f",Double.parseDouble(balanceOk.group(1)));
            liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(balanceOk.group(2)));
            setLegacyCurrentBankrollBadge("£"+liveSessionBankroll);
            // v15.10.178-UAT: BALANCE OK is an authoritative validated settlement
            // boundary. Publish the already-entered W/L/P into the visible journey
            // now, while the completed-hand / Amend screen is still displayed.
            ensureExplicitJourneyForCurrentHand();
        }

        // v15.10.174-UAT: split settlement emits SESSION BANKROLL before ACTUAL PLATFORM BALANCE.
        Matcher splitBalance=Pattern.compile("SESSION BANKROLL\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)\\s*\\|\\s*ACTUAL PLATFORM BALANCE\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(splitBalance.find()){
            liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(splitBalance.group(1)));
            livePlatformBalance=String.format(Locale.ROOT,"%.2f",Double.parseDouble(splitBalance.group(2)));
            setLegacyCurrentBankrollBadge("£"+liveSessionBankroll);
            // v15.10.178-UAT: split settlement is authoritative only after the
            // backend publishes both settled balances. Do not wait for Hand N+1.
            ensureExplicitJourneyForCurrentHand();
        }

        // Authoritative post-settlement session-money update.
        Matcher current=Pattern.compile("ACTUAL PLATFORM BALANCE\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)\\s*\\|\\s*SESSION BANKROLL\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(current.find()){
            livePlatformBalance=String.format(Locale.ROOT,"%.2f",Double.parseDouble(current.group(1)));
            liveSessionBankroll=String.format(Locale.ROOT,"%.2f",Double.parseDouble(current.group(2)));
            setLegacyCurrentBankrollBadge("£"+liveSessionBankroll);
            // v15.10.178-UAT: same post-settlement authority for the alternate
            // ACTUAL PLATFORM BALANCE | SESSION BANKROLL ordering.
            ensureExplicitJourneyForCurrentHand();
        }

        // Live wager badge follows the amount actually placed on the platform.
        Matcher wager=Pattern.compile("CHECK:\\s*Frozen recommended\\s+[^0-9-]*[0-9]+(?:\\.[0-9]+)?\\s+but entered wager is\\s+[^0-9-]*([0-9]+(?:\\.[0-9]+)?)",Pattern.CASE_INSENSITIVE).matcher(text);
        if(wager.find()){
            String w="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(wager.group(1)));
            currentWagerText=w;
            wagerBadge.setText("CURRENT WAGER: "+w);
            wagerBadge.revalidate();
            wagerBadge.repaint();
        }

        // v15.10.163 UAT: Frozen live path. The CLI confirms an accepted
        // wager with "Wager: MATCH reference" after entry; refresh the GUI
        // from the authoritative wager value already held by the live state.
        if(text.toLowerCase(Locale.ROOT).contains("wager: match reference") &&
           currentBaseWagerValue==currentBaseWagerValue){
            // v15.10.180-UAT: this marker remains in the accumulated transcript and is
            // re-seen after SPLIT/DOUBLE.  It confirms only the base wager; once additional
            // exposure is committed, the badge must continue to show total round exposure.
            double displayWager=(currentCommittedWagerValue==currentCommittedWagerValue)
                    ? currentCommittedWagerValue : currentBaseWagerValue;
            String w="£"+String.format(Locale.ROOT,"%.2f",displayWager);
            currentWagerText=w;
            wagerBadge.setText("CURRENT WAGER: "+w);
            wagerBadge.revalidate();
            wagerBadge.repaint();
        }

    }

    private boolean legacyStartingBankrollUnset() {
        String x=startingBankrollText==null?"":startingBankrollText.trim();
        return x.isEmpty() || x.equals("?") || x.equals("—") || x.equals("-");
    }

    private void setLegacyCurrentBankrollBadge(String currentText) {
        currentBankrollText=currentText;
        currentBankrollBadge.setText("CURRENT BANKROLL: "+currentText);
        currentBankrollBadge.revalidate();
        currentBankrollBadge.repaint();
    }

    private void setLegacyBankrollBadges(String startText,String currentText) {
        startingBankrollText=startText;
        currentBankrollText=currentText;
        bankrollBadge.setText("STARTING BANKROLL: "+startText);
        currentBankrollBadge.setText("CURRENT BANKROLL: "+currentText);
        bankrollBadge.revalidate();
        currentBankrollBadge.revalidate();
        bankrollBadge.repaint();
        currentBankrollBadge.repaint();
    }

    private void updateDeclaredScaleFromBackend(String text) {
        if(text==null)return;
        Matcher m=Pattern.compile("DECLARED SCALE LOCKED BEFORE HAND 1 \\\\| bankroll £([0-9.]+) \\\\| research frame £100.00 \\\\| scale 1:([0-9.]+)").matcher(text);
        if(m.find()){
            scaledWagerNote.setText("Bankroll £"+m.group(1)+" • declared scale 1:"+m.group(2));
            // The declared execution bankroll is the player's live starting
            // bankroll. Populate both live badges immediately at declaration;
            // later platform/account balance entry must never redefine STARTING.
            String declared="£"+String.format(Locale.ROOT,"%.2f",Double.parseDouble(m.group(1)));
            setLegacyBankrollBadges(declared,declared);
        }
        Matcher w=Pattern.compile("SCALED FROZEN WAGERS \\\\| £([0-9.]+) / £([0-9.]+) / £([0-9.]+) = Frozen £15 / £20 / £30").matcher(text);
        if(w.find()){
            scaledWagerValues.setText("£"+w.group(1)+"  •  £"+w.group(2)+"  •  £"+w.group(3));
        }
        if(text.contains("EXECUTION SCALE: STANDARD £100 research frame")){
            scaledWagerValues.setText("£15  •  £20  •  £30");
            scaledWagerNote.setText("Standard £100 frame • scaling OFF");
            scaledWagerEquivalent.setText("Frozen research denominations");
        }
    }

    private void installLiveCardRatePanel() {
        liveCardRatePanel.setLayout(new GridLayout(2,1,0,5));
        liveCardRatePanel.setBackground(new Color(248,250,252));
        liveCardRatePanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(205,216,226)),
                BorderFactory.createEmptyBorder(8,9,8,9)));
        liveCardRatePanel.add(liveRateRow("3-CARD WIN RATE",liveThreeRate,liveThreeCount));
        liveCardRatePanel.add(liveRateRow("4+ CARD WIN RATE",liveFourRate,liveFourCount));
        getLayeredPane().add(liveCardRatePanel,JLayeredPane.PALETTE_LAYER);
        repositionLiveCardRatePanel();
        javax.swing.Timer refresh=new javax.swing.Timer(700,e->{
            boolean personal="PERSONAL".equalsIgnoreCase(liveMode);
            liveCardRatePanel.setVisible(personal);
            if(personal)refreshLiveRates();
        });
        refresh.start(); liveCardRatePanel.putClientProperty("refreshTimer",refresh);
    }
    private JPanel liveRateRow(String title,JLabel rate,JLabel count){
        JPanel p=new JPanel(new BorderLayout(5,0)); p.setOpaque(false);
        JLabel t=new JLabel("<html><b>"+title+"</b></html>"); t.setFont(new Font("SansSerif",Font.BOLD,11)); t.setForeground(new Color(10,41,75));
        rate.setFont(new Font("SansSerif",Font.BOLD,18)); rate.setForeground(new Color(30,105,170));
        count.setFont(new Font("SansSerif",Font.PLAIN,9)); count.setForeground(new Color(92,103,113));
        JPanel r=new JPanel();r.setOpaque(false);r.setLayout(new BoxLayout(r,BoxLayout.Y_AXIS));rate.setAlignmentX(Component.CENTER_ALIGNMENT);count.setAlignmentX(Component.CENTER_ALIGNMENT);r.add(rate);r.add(count);
        p.add(t,BorderLayout.WEST);p.add(r,BorderLayout.EAST);return p;
    }
    private void repositionLiveCardRatePanel(){
        if(liveCardRatePanel==null)return; int w=235,h=92;
        int x=Math.max(20,getLayeredPane().getWidth()-w-24); int y=52;
        liveCardRatePanel.setBounds(x,y,w,h); liveCardRatePanel.revalidate(); liveCardRatePanel.repaint();
    }
    @Override
    JPanel splitBranch(String title,String original,List<String> extras,List<Boolean> doubled) {
        java.util.List<String> snapshot=title.equalsIgnoreCase("SPLIT A")?liveSplitACards:title.equalsIgnoreCase("SPLIT B")?liveSplitBCards:null;
        if(snapshot!=null){
            liveSplitHand=liveCurrentHand;
            snapshot.clear();
            if(title.equalsIgnoreCase("SPLIT A")) liveSplitADoubleCard="";
            if(title.equalsIgnoreCase("SPLIT B")) liveSplitBDoubleCard="";
            if(original!=null&&!original.isBlank())snapshot.add(original);
            if(extras!=null){
                snapshot.addAll(extras);
                if(doubled!=null){
                    for(int i=0;i<Math.min(extras.size(),doubled.size());i++){
                        if(Boolean.TRUE.equals(doubled.get(i))){
                            if(title.equalsIgnoreCase("SPLIT A")) liveSplitADoubleCard=extras.get(i);
                            if(title.equalsIgnoreCase("SPLIT B")) liveSplitBDoubleCard=extras.get(i);
                        }
                    }
                }
            }
        }
        String outcome = title.equalsIgnoreCase("SPLIT A") ? liveSplitAOutcome
                       : title.equalsIgnoreCase("SPLIT B") ? liveSplitBOutcome : "";
        // v15.10.193-UAT: once dealer reconstruction is visible, the child split
        // outcomes are already deterministic. Show WIN/PUSH/LOSS immediately at
        // CARD CHECK rather than waiting for the later balance-check settlement line.
        // Settlement remains authoritative; this is presentation only.
        if(outcome.isBlank() && dealerHole!=null && !dealerHole.isBlank()) {
            java.util.List<String> branchCards=new ArrayList<>();
            if(original!=null && !original.isBlank()) branchCards.add(original);
            if(extras!=null) branchCards.addAll(extras);
            java.util.List<String> dealerCards=new ArrayList<>();
            if(up!=null && !up.isBlank()) dealerCards.add(up);
            dealerCards.add(dealerHole);
            if(dealerDraws!=null) dealerCards.addAll(dealerDraws);
            int playerTotal=displayHandTotal(branchCards.toArray(new String[0]));
            int dealerTotal=displayHandTotal(dealerCards.toArray(new String[0]));
            if(playerTotal>21) outcome="L";
            else if(dealerTotal>21 || playerTotal>dealerTotal) outcome="W";
            else if(playerTotal<dealerTotal) outcome="L";
            else outcome="P";
        }
        String shownTitle=title;
        if(!outcome.isBlank()) {
            String word=outcome.equals("W")?"WIN":outcome.equals("L")?"LOSS":outcome.equals("P")?"PUSH":"";
            if(!word.isBlank()) shownTitle=title+" ("+word+")";
        }
        return super.splitBranch(shownTitle,original,extras,doubled);
    }

    private int currentSinglePlayerCardCount(){
        if(splitActive)return 0; int n=0;
        if(p1!=null&&!p1.isBlank())n++; if(p2!=null&&!p2.isBlank())n++;
        if(playerExtras!=null)n+=playerExtras.size(); return n;
    }
    private void capturePendingRateHand(){pendingPlayerCardCount=currentSinglePlayerCardCount();pendingHand=currentHand;}
    private void recordLiveRate(String outcome){
        if(!"PERSONAL".equalsIgnoreCase(liveMode)||pendingHand<=0||pendingHand==lastRateHand)return;
        int c=pendingPlayerCardCount; if(c<3)return;
        boolean win="W".equalsIgnoreCase(outcome)||"WIN".equalsIgnoreCase(outcome);
        if(c==3){live3Resolved++;if(win)live3Wins++;}else if(c>=4){live4Resolved++;if(win)live4Wins++;}
        lastRateHand=pendingHand;refreshLiveRates();
    }
    private void refreshLiveRates(){
        liveThreeRate.setText(live3Resolved==0?"—":String.format(Locale.ROOT,"%.1f%%",100.0*live3Wins/live3Resolved));
        liveFourRate.setText(live4Resolved==0?"—":String.format(Locale.ROOT,"%.1f%%",100.0*live4Wins/live4Resolved));
        liveThreeCount.setText(live3Resolved==0?"0 resolved":live3Wins+" wins / "+live3Resolved+" resolved");
        liveFourCount.setText(live4Resolved==0?"0 resolved":live4Wins+" wins / "+live4Resolved+" resolved");
    }
    private boolean journeyAlreadyHasCurrentHand(){
        if(recentJourney==null) return false;
        String prefix="H"+currentHand+" ";
        for(String row:recentJourney) if(row!=null && row.startsWith(prefix)) return true;
        return false;
    }

    // v15.10.229-UAT: the condensed completion path can bypass the inherited
    // commitCurrentHand() presentation snapshot.  Publish only the completed hand
    // into PREVIOUS HAND; do not touch evidence streams, hole history or settlement.
    private void publishCompletedHandToPreviousPanel(){
        java.util.List<String> cards=!completedHandPresentationSnapshot.isEmpty()
                ? new ArrayList<>(completedHandPresentationSnapshot) : currentPhysicalCards();
        if(cards==null || cards.isEmpty()) return;
        previousHandPhysical.clear();
        previousHandPhysical.addAll(cards);
        refreshCards();
    }

    private void ensureExplicitJourneyForCurrentHand(){
        if(currentHand<=0 || liveLastResult.isBlank() || journeyAlreadyHasCurrentHand()) return;
        String outcome=liveLastResult.equalsIgnoreCase("L")?"LOSS":liveLastResult.equalsIgnoreCase("W")?"WIN":liveLastResult.equalsIgnoreCase("P")?"PUSH":"?";
        String wager=(currentWagerText==null||currentWagerText.isBlank()||currentWagerText.equals("—"))?"£?":currentWagerText;
        String direction=liveDirectionMarker("");
        int cards=(pendingHand==currentHand && pendingPlayerCardCount>0) ? pendingPlayerCardCount : currentSinglePlayerCardCount();
        String taken=(outcome.equals("WIN") && !splitActive) ? (cards==3?"3":(cards>=4?"4+":"—")) : "—";
        recentJourney.add("H"+currentHand+" "+outcome+" "+wager+" ("+direction+") | PLAYER CARDS TAKEN "+taken);
        while(recentJourney.size()>5) recentJourney.remove(0);
        lastExplicitJourneyHand=currentHand;
        journeyBadge.showItems(recentJourney);
        decorateJourneyCardCounts();
    }

    @Override
    void captureRecentPlayerExperience(){
        // Preserve the authoritative inherited history capture, then add one compact
        // descriptive row value for the just-completed hand. Splits and hands
        // outside the 3 / 4+ comparison are deliberately shown as an em dash.
        int cards=(pendingHand==currentHand && pendingPlayerCardCount>0) ? pendingPlayerCardCount : currentSinglePlayerCardCount();
        boolean wasSplit=splitActive;
        // v15.10.175: if the explicit post-settlement boundary already committed this
        // hand, do not let the inherited parser append a duplicate/stale row.
        if(!journeyAlreadyHasCurrentHand()) super.captureRecentPlayerExperience();
        if(recentJourney==null||recentJourney.isEmpty())return;
        int i=recentJourney.size()-1;
        String row=recentJourney.get(i);
        // v15.10.167-UAT: the operator-entered/validated result is authoritative for
        // the live journey strip. The inherited transcript parser can miss a result
        // after the condensed GUI transcript and previously fell through to PUSH.
        String authoritativeOutcome=liveLastResult.equalsIgnoreCase("L")?"LOSS":liveLastResult.equalsIgnoreCase("W")?"WIN":liveLastResult.equalsIgnoreCase("P")?"PUSH":"";
        if(row!=null&&!authoritativeOutcome.isBlank()){
            row=row.replaceFirst("(?i)(H\\d+\\s+)(?:WIN|LOSS|PUSH|\\?)(\\s+)","$1"+authoritativeOutcome+"$2");
            recentJourney.set(i,row);
            journeyBadge.showItems(recentJourney);
        }
        boolean won=row!=null && row.toUpperCase(Locale.ROOT).contains("WIN");
        String taken=(!won || wasSplit)?"—":(cards==3?"3":(cards>=4?"4+":"—"));
        if(row!=null){
            // v15.10.221-UAT: explicit settlement rows are born with a placeholder
            // PLAYER CARDS TAKEN em dash. Replace that placeholder after the hand is
            // resolved instead of only appending when the marker is absent.
            if(row.contains("PLAYER CARDS TAKEN"))
                row=row.replaceFirst("(?i)PLAYER CARDS TAKEN\\s+(?:4\\+|3|—)", "PLAYER CARDS TAKEN "+taken);
            else
                row=row+" | PLAYER CARDS TAKEN "+taken;
            recentJourney.set(i,row);
            journeyBadge.showItems(recentJourney);
        }
        decorateJourneyCardCounts();
    }
    private void installLiveV2ObservationPanel(){
        liveV2ObservationPanel.setOpaque(false);
        // v15.10.213-UAT: keep the AI V2 region geometrically stable even when short
        // S/local-shuffle states replace longer observation text.
        liveV2ObservationPanel.setPreferredSize(new Dimension(420,106));
        liveV2ObservationPanel.setMinimumSize(new Dimension(360,106));
        liveV2ObservationPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE,106));
        decorateJourneyCardCounts();
    }

    private JLabel liveV2Label(String text, boolean bold){
        JLabel l=new JLabel(text);
        l.setForeground(new Color(18,48,82));
        l.setFont(new Font("SansSerif",bold?Font.BOLD:Font.PLAIN,10));
        return l;
    }

    private void rebuildLiveV2ObservationPanel(){
        liveV2ObservationPanel.removeAll();
        GridBagConstraints g=new GridBagConstraints();
        g.insets=new Insets(0,2,1,2); g.fill=GridBagConstraints.HORIZONTAL; g.weightx=1; g.gridx=0; g.gridy=0; g.gridwidth=2;
        JLabel head=liveV2Label("ULTIMATE AI V2 — OBSERVATION",true); head.setHorizontalAlignment(SwingConstants.CENTER);
        liveV2ObservationPanel.add(head,g);
        String[][] rows={{"Evidence state (10A–10F)",(liveV2StateChangedMark?"▲ ":"")+liveV2EvidenceState},{"Prospective watch (10G)",(liveV2WatchChangedMark?"▲ ":"")+liveV2Watch},{"Evidence confidence (10H–10I)",(liveV2ConfidenceChangedMark?"▲ ":"")+liveV2Confidence},{"V2 controller (P6–P8)",(liveV2ControllerChangedMark?"▲ ":"")+liveV2ControllerState},{"Decision influence","NONE"}};
        for(String[] r:rows){
            g.gridy++; g.gridwidth=1; g.gridx=0; g.weightx=.58;
            JLabel key=liveV2Label(r[0],false);
            key.setPreferredSize(new Dimension(168,16)); key.setMinimumSize(new Dimension(150,16));
            liveV2ObservationPanel.add(key,g);
            g.gridx=1; g.weightx=.42;
            JLabel v=liveV2Label(r[1],true); v.setHorizontalAlignment(SwingConstants.CENTER); v.setOpaque(true);
            v.setPreferredSize(new Dimension(145,16)); v.setMinimumSize(new Dimension(125,16));
            v.setBackground(r[0].startsWith("Evidence confidence")?new Color(250,231,155):(r[0].startsWith("Evidence state")?new Color(190,220,245):new Color(232,238,240)));
            v.setBorder(BorderFactory.createLineBorder(new Color(60,105,135)));
            if(r[0].startsWith("Evidence state")) liveV2StateValueLabel=v;
            else if(r[0].startsWith("Prospective watch")) liveV2WatchValueLabel=v;
            else if(r[0].startsWith("V2 controller")) liveV2ControllerValueLabel=v;
            else if(r[0].startsWith("Evidence confidence")) liveV2ConfidenceValueLabel=v;
            liveV2ObservationPanel.add(v,g);
        }
        liveV2ObservationPanel.revalidate(); liveV2ObservationPanel.repaint();
    }

    private JLabel liveV2StateValueLabel, liveV2WatchValueLabel, liveV2ConfidenceValueLabel, liveV2ControllerValueLabel;
    // v15.10.227-UAT: persistent symbolic change markers; colour is no longer the only cue.
    private boolean liveV2StateChangedMark=false, liveV2WatchChangedMark=false, liveV2ConfidenceChangedMark=false, liveV2ControllerChangedMark=false;
    private javax.swing.Timer liveV2StrobeTimer;
    private void startLiveV2StateStrobe(JLabel target, long durationMs){
        if(liveV2StrobeTimer!=null) liveV2StrobeTimer.stop();
        if(target==null) return;
        final Border normal=target.getBorder();
        final Border pulse=BorderFactory.createCompoundBorder(
            BorderFactory.createLineBorder(new Color(245,175,35),3),
            BorderFactory.createEmptyBorder(0,0,0,0));
        final long until=System.currentTimeMillis()+durationMs;
        liveV2StrobeTimer=new javax.swing.Timer(350,null);
        liveV2StrobeTimer.addActionListener(e->{
            if(System.currentTimeMillis()>=until){
                liveV2StrobeTimer.stop();
                target.setBorder(normal);
                target.repaint();
                return;
            }
            target.setBorder(target.getBorder()==pulse?normal:pulse);
            target.repaint();
        });
        liveV2StrobeTimer.start();
    }

    private void updateLiveV2ObservationFromBackend(String text){
        if(text==null||text.isBlank())return;
        String oldState=liveV2EvidenceState, oldWatch=liveV2Watch, oldConfidence=liveV2Confidence, oldController=liveV2ControllerState;
        Matcher f=Pattern.compile("AI V2 PHASE 10F\\s*\\|[^\\r\\n]*?ASSESSMENT\\s+([A-Z_]+)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(f.find())liveV2EvidenceState=f.group(1).replace('_',' ').toUpperCase(Locale.ROOT);
        Matcher w=Pattern.compile("AI V2 PHASE 10G\\s*\\|[^\\r\\n]*?WATCH\\s+([A-Z_]+)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(w.find())liveV2Watch=w.group(1).replace('_',' ').toUpperCase(Locale.ROOT);
        Matcher c=Pattern.compile("AI V2 PHASE 10I\\s*\\|[^\\r\\n]*?CALIBRATION_STATUS\\s+([A-Z_]+)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(c.find()){String x=c.group(1).toUpperCase(Locale.ROOT);liveV2Confidence=x.equals("EARLY")?"TOO EARLY":x.replace('_',' ');}
        Matcher p6=Pattern.compile("(?:CONTROLLER STATE|POST-EXIT STATUS)[^\\r\\n]*?\\|\\s*(POST[- ]EXIT WATCH|RE[- ]ENTRY CANDIDATE|RE[- ]ENTRY CONFIRMED)",Pattern.CASE_INSENSITIVE).matcher(text);
        while(p6.find()) liveV2ControllerState=p6.group(1).toUpperCase(Locale.ROOT).replace(' ','-');
        if(Pattern.compile("PHASE 6\\s*\\|\\s*EXIT LATCHED",Pattern.CASE_INSENSITIVE).matcher(text).find()) liveV2ControllerState="POST-EXIT WATCH";
        // Phase 7/8 are later controller authorities than the generic Phase-6 journey line.
        if(Pattern.compile("PHASE 7\\s*\\|\\s*RE-ENTRY CONFIRMED",Pattern.CASE_INSENSITIVE).matcher(text).find() ||
           Pattern.compile("PHASE 8\\s*\\|\\s*RE-ENTRY EXPOSURE (?:BASE|MEDIUM|HIGH)",Pattern.CASE_INSENSITIVE).matcher(text).find())
            liveV2ControllerState="RE-ENTRY CONFIRMED";
        if(Pattern.compile("PHASE [678]\\s*\\|[^\\r\\n]*WITHDRAWN",Pattern.CASE_INSENSITIVE).matcher(text).find())
            liveV2ControllerState="POST-EXIT WATCH";
        boolean stateChanged=!oldState.equals(liveV2EvidenceState), watchChanged=!oldWatch.equals(liveV2Watch), confidenceChanged=!oldConfidence.equals(liveV2Confidence), controllerChanged=!oldController.equals(liveV2ControllerState);
        if(stateChanged) liveV2StateChangedMark=true;
        if(watchChanged) liveV2WatchChangedMark=true;
        if(confidenceChanged) liveV2ConfidenceChangedMark=true;
        if(controllerChanged) liveV2ControllerChangedMark=true;
        SwingUtilities.invokeLater(()->{ rebuildLiveV2ObservationPanel(); decorateJourneyCardCounts(); });
    }

    private void decorateJourneyCardCounts(){
        // v15.10.218-UAT: presentation-only containment repair.
        // Rebuild both instruments from authoritative recentJourney data on every refresh.
        // Never reuse children already mounted in journeyBadge: doing so recursively nested
        // the previous left instrument each time dynamic transcript/prompt text changed.
        final int instrumentHeight=112;
        final Color instrumentBg=new Color(255,248,204);
        final Border instrumentBorder=new CompoundBorder(
            BorderFactory.createLineBorder(new Color(185,154,58)),
            BorderFactory.createEmptyBorder(3,7,3,7));

        journeyBadge.removeAll();
        journeyBadge.setLayout(new GridLayout(1,2,8,0));
        journeyBadge.setPreferredSize(new Dimension(980,instrumentHeight));
        journeyBadge.setMinimumSize(new Dimension(900,instrumentHeight));
        journeyBadge.setMaximumSize(new Dimension(Integer.MAX_VALUE,instrumentHeight));

        JPanel left=new JPanel(new BorderLayout(8,0));
        left.setOpaque(true); left.setBackground(instrumentBg); left.setBorder(instrumentBorder);
        left.setPreferredSize(new Dimension(540,106)); left.setMinimumSize(new Dimension(500,106));
        left.setMaximumSize(new Dimension(Integer.MAX_VALUE,106));

        JLabel leftHead=new JLabel("<html><b>RECENT PLAYER ANALYSIS</b><br><span style='font-size:8px'>NUMBER PLAYER CARDS ON WIN</span></html>");
        leftHead.setForeground(new Color(18,48,82));
        leftHead.setFont(new Font("SansSerif",Font.BOLD,9));
        leftHead.setPreferredSize(new Dimension(165,72));
        leftHead.setMinimumSize(new Dimension(165,72));
        leftHead.setToolTipText("Bottom row shows player-card count on wins only: 3, 4+, or — otherwise");
        left.add(leftHead,BorderLayout.WEST);

        JPanel hands=new JPanel(new GridLayout(1,5,6,0));
        hands.setOpaque(false);
        java.util.List<String> visible=new ArrayList<>();
        if(recentJourney!=null){
            int from=Math.max(0,recentJourney.size()-5);
            for(int i=from;i<recentJourney.size();i++) visible.add(recentJourney.get(i));
        }
        for(int slot=0;slot<5;slot++){
            JPanel hand=new JPanel(); hand.setOpaque(false); hand.setLayout(new BoxLayout(hand,BoxLayout.Y_AXIS));
            String shortOut="—", wager="", dir="", taken="—";
            if(slot<visible.size()){
                String src=visible.get(slot)==null?"":visible.get(slot);
                Matcher m=Pattern.compile("H(\\d+)\\s+(WIN|LOSS|PUSH|\\?)\\s+(£[^\\s]+)\\s+\\(([FP?])\\)",Pattern.CASE_INSENSITIVE).matcher(src);
                if(m.find()){
                    String out=m.group(2).toUpperCase(Locale.ROOT);
                    shortOut=out.equals("WIN")?"W":out.equals("LOSS")?"L":out.equals("PUSH")?"P":"?";
                    wager=m.group(3); dir="("+m.group(4).toUpperCase(Locale.ROOT)+")";
                }
                int q=src.lastIndexOf("PLAYER CARDS TAKEN ");
                if(q>=0)taken=src.substring(q+19).trim();
            }
            JLabel result=new JLabel(shortOut,SwingConstants.CENTER);
            result.setForeground(shortOut.equals("W")?new Color(25,125,75):shortOut.equals("L")?new Color(190,55,50):new Color(85,95,105));
            result.setFont(new Font("SansSerif",Font.BOLD,17)); result.setAlignmentX(Component.CENTER_ALIGNMENT);
            JLabel wagerLabel=new JLabel(wager,SwingConstants.CENTER); wagerLabel.setForeground(new Color(55,68,78)); wagerLabel.setFont(new Font("SansSerif",Font.BOLD,10)); wagerLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
            JLabel dirLabel=new JLabel(dir,SwingConstants.CENTER); dirLabel.setForeground(new Color(115,125,132)); dirLabel.setFont(new Font("SansSerif",Font.PLAIN,8)); dirLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
            JLabel cardsTaken=new JLabel(taken,SwingConstants.CENTER); cardsTaken.setForeground(new Color(30,105,170)); cardsTaken.setFont(new Font("SansSerif",Font.BOLD,10)); cardsTaken.setAlignmentX(Component.CENTER_ALIGNMENT);
            cardsTaken.setToolTipText("Number of player cards on a winning hand: 3, 4+, or — otherwise");
            hand.add(result); hand.add(wagerLabel); hand.add(dirLabel); hand.add(cardsTaken); hands.add(hand);
        }
        left.add(hands,BorderLayout.CENTER);

        rebuildLiveV2ObservationPanel();
        liveV2ObservationPanel.setOpaque(true);
        liveV2ObservationPanel.setBackground(instrumentBg);
        liveV2ObservationPanel.setBorder(instrumentBorder);
        liveV2ObservationPanel.setPreferredSize(new Dimension(430,86));
        liveV2ObservationPanel.setMinimumSize(new Dimension(400,86));
        liveV2ObservationPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE,86));

        journeyBadge.add(left);
        journeyBadge.add(liveV2ObservationPanel);
        journeyBadge.revalidate(); journeyBadge.repaint();
    }

    private void installLiveRateCapture(){
        MouseAdapter pre=new MouseAdapter(){@Override public void mousePressed(MouseEvent e){capturePendingRateHand();}};
        win.addMouseListener(pre);loss.addMouseListener(pre);push.addMouseListener(pre);
        win.addActionListener(e->recordLiveRate("W"));loss.addActionListener(e->recordLiveRate("L"));push.addActionListener(e->recordLiveRate("P"));
        terminalInput.addKeyListener(new KeyAdapter(){@Override public void keyPressed(KeyEvent e){if(e.getKeyCode()==KeyEvent.VK_ENTER){String x=terminalInput.getText().trim().toUpperCase(Locale.ROOT);if(x.equals("W")||x.equals("WIN")||x.equals("L")||x.equals("LOSS")||x.equals("P")||x.equals("PUSH")){capturePendingRateHand();String o=x.substring(0,1);SwingUtilities.invokeLater(()->recordLiveRate(o));}}}});
    }

    private void replaceVisibleVersionText(Component c) {
        if (c instanceof JLabel) {
            JLabel l = (JLabel)c;
            String t = l.getText();
            if (t != null) {
                String updated = t.replace("15.10.15-UAT", UI_VERSION).replace("15.10.39", "15.10.42").replace("15.10.40", "15.10.42");
                if(updated.contains("Keyboard-first live entry")) updated=updated.replaceFirst("^\\d+\\.\\d+\\.\\d+(?:-UAT)?\\s*•\\s*", UI_VERSION+" • ");
                // v15.10.142: keep the established header height/boxes, but prevent
                // the version from being clipped by moving it onto the subtitle line.
                if (updated.contains("DETERMINISTIC BLACKJACK") && updated.contains("LIVE GRAPHICAL COMPANION")) {
                    updated = updated.replace(" • " + UI_VERSION, "").replace(UI_VERSION, "").trim();
                } else if (updated.contains("Keyboard-first live entry") && !updated.contains(UI_VERSION)) {
                    updated = UI_VERSION + " • " + updated;
                }
                l.setText(updated);
            }
        } else if (c instanceof AbstractButton) {
            AbstractButton b = (AbstractButton)c;
            String t = b.getText();
            if (t != null) b.setText(t.replace("15.10.15-UAT", UI_VERSION).replace("15.10.39", "15.10.42").replace("15.10.40", "15.10.42"));
        }
        if (c instanceof Container) {
            for (Component child : ((Container)c).getComponents()) replaceVisibleVersionText(child);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new BlackjackLiveGuiUAT().setVisible(true));
    }
}
