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.
public class BlackjackSessionReplay extends JFrame {
    // 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.179, 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.172: 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.169 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.149: 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.218";
    // 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.198-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("Ⅱ PAUSE");
    private final JButton nextButton = new JButton("NEXT HAND");
    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 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 java.util.List<Session> sessions = 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(215,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(); if(handIndex < 0) nextHand(); }});
        pauseButton.addActionListener(e -> timer.stop());
        nextButton.addActionListener(e -> { timer.stop(); nextHand(); });
        compareButton.addActionListener(e -> { timer.stop(); openSelectedComparison(); });
        compareTargetBox.addActionListener(e -> updateCompareAvailability());
        openButton.addActionListener(e -> chooseFile());
        liveButton.addActionListener(e -> launchLiveViaReleaseLauncher());
        sessionBox.addActionListener(e -> {
            int i = sessionBox.getSelectedIndex();
            if (i >= 0 && i < sessions.size()) selectSession(sessions.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() {
        Path bat = applicationDirectory().resolve("RUN_LIVE_GUI.bat");
        if (!Files.exists(bat)) {
            JOptionPane.showMessageDialog(this,
                    "RUN_LIVE_GUI.bat was not found in the GUI folder:\n" + bat,
                    "Live Play launcher not found", JOptionPane.ERROR_MESSAGE);
            return;
        }
        try {
            new ProcessBuilder("cmd.exe", "/c", "start", "", bat.toString())
                    .directory(applicationDirectory().toFile())
                    .start();
        } catch (IOException ex) {
            JOptionPane.showMessageDialog(this,
                    "Could not launch RUN_LIVE_GUI.bat:\n" + ex.getMessage(),
                    "Live Play launch failed", JOptionPane.ERROR_MESSAGE);
        }
    }

    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(12,22,10,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, 46));
        sessionLabel.setPreferredSize(new Dimension(1200, 38));

        top.add(titleRow);
        top.add(Box.createVerticalStrut(2));
        top.add(sub);
        top.add(Box.createVerticalStrut(5));
        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);

        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.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.179 layout.
        resultLabel.setPreferredSize(new Dimension(1000,38));
        resultLabel.setMinimumSize(new Dimension(0,38));
        resultLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE,38));
        // 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());
                resultLabel.setBounds(0, Math.max(0, getHeight()-42), getWidth(), 38);
            }
            @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,34));
        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(openButton);
        left.add(liveButton);
        left.add(new JLabel("Session:"));
        sessionBox.setPreferredSize(new Dimension(220,30));
        left.add(sessionBox);

        JPanel transport = new JPanel(new FlowLayout(FlowLayout.CENTER,6,0));
        transport.setOpaque(false);
        transport.add(playButton);
        transport.add(pauseButton);
        transport.add(nextButton);

        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(105,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(78,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(100,30));
        compareRow.add(compareTargetBox);

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

        JPanel lower=new JPanel(new BorderLayout(12,0)); lower.setOpaque(false); lower.add(compareRow,BorderLayout.CENTER); 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];
    }

    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) {
        String extra = readSessionEvidenceIfPresent(sid);
        if (extra.isBlank()) return loadedOutputText;
        // Prefer the append-only corrected dedicated report for chronology/card identity,
        // while retaining output.txt for cumulative counterfactual/publication blocks.
        return loadedOutputText + "\n" + extra;
    }

    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"
        };
        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));
            }
            sessions.clear(); sessions.addAll(parsed);
            sessionBox.removeAllItems();
            for (Session s: sessions) sessionBox.addItem(s.id + "  •  " + s.hands.size() + " hands");
            int preferred=0;
            for(int i=0;i<sessions.size();i++) if(sessions.get(i).id.contains("20260903_004704")){preferred=i;break;}
            sessionBox.setSelectedIndex(preferred);
            selectSession(sessions.get(preferred));
            updateCompareAvailability();
            if (!suppressOpeningMemorySplash) showOpeningMemorySplash();
        } catch(Exception ex) {
            JOptionPane.showMessageDialog(this, ex.getMessage(), "Could not load replay", JOptionPane.ERROR_MESSAGE);
        }
    }

    private void showOpeningMemorySplash() {
        java.util.List<OpeningFrequency> top = openingFrequencies(sessions);
        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(sessions);
        double avgDecisionsPerSession = averageDecisionsPerSession(sessions);

        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.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.34; 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.66; 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("ALL-SESSIONS • ALL-PLAYER EVIDENCE 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(ss.publicationNumber<=0||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 — SEPARATE CONTEXT",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(s==null||s.publicationNumber<=0||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)){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=stat.supported+" behavioural path"+(stat.supported==1?"":"s");
        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,112));
        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);
        JLabel note=new JLabel(x.note,SwingConstants.CENTER);note.setFont(new Font("SansSerif",Font.BOLD,8));note.setForeground(x.excluded>0?new Color(190,48,48):new Color(76,91,105));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(sameVisibleAllocation(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(sameVisibleAllocation(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(sameVisibleAllocationCompare(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));
    }

    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() {
        FascinatingStats stats = fascinatingStats(sessions);
        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-session curiosities • 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));

        JPanel positive = new JPanel(new BorderLayout(8, 0));
        positive.setBackground(new Color(247, 249, 251));
        positive.setBorder(new CompoundBorder(new LineBorder(new Color(224, 230, 235)), new EmptyBorder(8, 10, 8, 10)));
        JLabel ph = new JLabel("PLAYER 10-CARD BUSTS OCCURRED IN " + stats.sessionsWithTenBust + " SESSIONS");
        ph.setFont(new Font("SansSerif", Font.BOLD, 12));
        ph.setForeground(new Color(58, 72, 86));
        JLabel pv = new JLabel(stats.positiveWithTenBust + " OF THOSE SESSIONS FINISHED IN PROFIT", SwingConstants.RIGHT);
        pv.setFont(new Font("SansSerif", Font.BOLD, 14));
        pv.setForeground(NAVY);
        positive.add(ph, BorderLayout.WEST); positive.add(pv, BorderLayout.EAST);
        positive.setMaximumSize(new Dimension(Integer.MAX_VALUE, 42));
        content.add(positive);
        content.add(Box.createVerticalStrut(10));

        // 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 • session profit/loss shown below each session");
        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){
                    JLabel pageTitle=new JLabel("CONTINUED — SESSIONS "+pagePoints.get(0).label+" TO "+pagePoints.get(pagePoints.size()-1).label);
                    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(430,430)); swp.setMinimumSize(new Dimension(330,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 = Ultimate AI • outline = same-basis Frozen • running profit/loss remains cumulative</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(430,410));ap.setMinimumSize(new Dimension(330,380));ap.setMaximumSize(new Dimension(Integer.MAX_VALUE,470));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;
        double chosenProfit=v.chosenBank-100.0;
        String profitText=Double.isNaN(v.chosenBank)?"":String.format(Locale.ROOT," (PROFIT: %s£%.2f)",chosenProfit>0.004?"+":chosenProfit<-0.004?"-":"",Math.abs(chosenProfit));
        String profitColour=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 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);
        JLabel source=new JLabel(" "); source.setFont(new Font("SansSerif",Font.BOLD,12)); source.setForeground(new Color(92,103,113)); source.setAlignmentX(Component.LEFT_ALIGNMENT);
        body.add(marker); body.add(headline); body.add(Box.createVerticalStrut(3)); body.add(detail); body.add(Box.createVerticalStrut(4)); body.add(source);
        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>");
                source.setText("<html><div style='width:540px'>"+html(q.source + (q.displayMs>10000 ? "  •  HELD FOR 20 SECONDS" : ""))+"</div></html>");
                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;
        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.hands==null||s.hands.isEmpty())continue;

            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 — Session %d, Hands %d–%d.",maxWinRun,winS.publicationNumber,winH-maxWinRun+1,winH),"OBSERVED PLAYER JOURNEYS — SESSIONS 1–11",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 — Session %d, Hands %d–%d.",maxBankWinGain,maxBankWinRun,bankWinS.publicationNumber,bankWinStartH,bankWinEndH),"OBSERVED PLAYER BANKROLL PATHS — SESSIONS 1–11",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 — Session %d, Hands %d–%d.",maxBankLossRun,maxBankLossDrop,bankLossS.publicationNumber,bankLossStartH,bankLossEndH),"OBSERVED PLAYER BANKROLL PATHS — SESSIONS 1–11",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 — Session %d, spanning Hands %d–%d.",maxPlayerNonTenStreamRun,playerNonTenStreamS.publicationNumber,playerNonTenStartH,playerNonTenEndH),"OBSERVED PLAYER CARD STREAM — SESSIONS 1–11",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) — Session %d, spanning Hands %d–%d.",maxPlayerTenStreamRun,playerTenStreamS.publicationNumber,playerTenStartH,playerTenEndH),"OBSERVED PLAYER CARD STREAM — SESSIONS 1–11",new Color(30,105,170),20000);
        if(eligibleObservedSessions>0)addCuriosity(out,"PLAYER SESSIONS REACHING £200",String.format(Locale.ROOT,"%.1f%% — %d of %d observed numbered sessions reached a research-equivalent bankroll of at least £200.",100.0*reach200Sessions/eligibleObservedSessions,reach200Sessions,eligibleObservedSessions),"OBSERVED PLAYER JOURNEYS — SESSIONS 1–11",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 — ALL NUMBERED SESSIONS",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 the actual numbered observed sessions so a correction
            // replay is never counted as a second independent session.
            if (s.publicationNumber <= 0) 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" + 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&&v.aiComplete&&!Double.isNaN(v.aiBank))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<>();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;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));
        // 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 = AI final bankroll; outline = same-basis Frozen. Running totals are cumulative and never reset between continuation charts.");}
        @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=190,right=10,top=108,bottom=68,pw=Math.max(40,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(!Double.isNaN(v.aiBank)){aip+=v.aiBank-100;nSup++;}if(!Double.isNaN(v.frozenBank))fp+=v.frozenBank-100;else fc=false;}int sx=16,sy=112;g2.setColor(NAVY);g2.setFont(new Font("SansSerif",Font.BOLD,14));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,12));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()),slot=Math.max(44,pw/n),bar=Math.max(9,Math.min(16,(slot-16)/2)),base=top+ph;for(int i=0;i<pts.size();i++){UltimateAIView v=pts.get(i);int cx=left+i*slot+slot/2;if(!Double.isNaN(v.aiBank)){int y=top+ph-(int)Math.round(v.aiBank/max*ph);g2.setColor(new Color(64,91,122));g2.fillRoundRect(cx-bar-3,y,bar,Math.max(1,base-y),5,5);StatWatchBarPanel.drawLiveCasinoInsideBar(g2,cx-bar-3,y,bar,Math.max(1,base-y),Color.WHITE);StatWatchBarPanel.drawVerticalLabelAboveBar(g2,"AI H"+v.hands+"  £"+String.format(Locale.ROOT,"%.2f",v.aiBank),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);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));}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);g2.setFont(new Font("SansSerif",Font.PLAIN,7));String ov="A"+v.actionOverrides+" W"+v.wagerOverrides+" E"+v.exitOverrides;g2.drawString(ov,cx-g2.getFontMetrics().stringWidth(ov)/2,base+31);}g2.setFont(new Font("SansSerif",Font.BOLD,8));g2.setColor(new Color(62,72,81));String axis1="Session • override key";String axis2="A=action • W=wager • E=exit";g2.drawString(axis1,left+Math.max(0,(pw-g2.getFontMetrics().stringWidth(axis1))/2),h-29);g2.drawString(axis2,left+Math.max(0,(pw-g2.getFontMetrics().stringWidth(axis2))/2),h-16);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.indexOf(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=160,right=42,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(42,pw/n),bar=Math.max(9,Math.min(16,(slot-16)/2));
            for(int i=0;i<pts.size();i++){
                StatWatchView v=pts.get(i); int cx=left+i*slot+slot/2, 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);
                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);
                    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+(v.exitHand>0?"  "+shortExitCause(v.exitReason):"");
                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);
            // Keep the original vertical-on-bar treatment, but reserve the full rotated text width above it
            // 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<58 || width<8)return;
            String s="LIVE CASINO";
            Font f=new Font("SansSerif",Font.BOLD,Math.max(7,Math.min(9,width-4)));
            FontMetrics fm=g2.getFontMetrics(f);
            if(fm.stringWidth(s)>height-8)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);
                        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.32, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, "<html>DEALER<br>&nbsp;</html>", 1, 0.18, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, "<html>OCCUR-<br>RENCE</html>", 2, 0.24, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, "<html>AVG<br>WAGER</html>", 3, 0.26, SwingConstants.RIGHT, 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.32, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, o.dealer, 1, 0.18, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, String.valueOf(o.count), 2, 0.24, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, "£" + money(o.averageWager()), 3, 0.26, SwingConstants.RIGHT, 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, 11));
        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(5, 7, 5, 7)));
        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 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.
        if (!isHybridSession(s) && "Hybrid".equalsIgnoreCase(String.valueOf(compareTargetBox.getSelectedItem()))) {
            compareTargetBox.setSelectedItem("Fixed-Policy Casual");
        }
        updateCompareAvailability();
        timer.stop(); handIndex=-1; stage=0;
        sessionLabel.setText(sessionDisplayTitle(s) + " (" + sessionArchitectureLabel(s) + ")");
        clearTable();
        handLabel.setText("HAND -- / " + s.hands.size());
        bankrollLabel.setText(s.summary.isBlank()?"Historical replay":s.summary);
        chipDisplay.setValues(0, 0);
        dealerTotalLabel.setText("TOTAL --"); playerTotalLabel.setText("TOTAL --");
        wagerLabel.setText("Press PLAY");
        actionLabel.setText("Ready — playback uses the recorded historical hand evidence."); journeyStrip.showHistory(null, -1); shoePanel.reset(); previousPanel.clear();
    }

    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));
        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 nextHand() {
        if(currentSession==null) return;
        handIndex++;
        if(handIndex>=currentSession.hands.size()) {
            timer.stop(); handIndex=currentSession.hands.size()-1;
            actionLabel.setText("SESSION REPLAY COMPLETE");
            resultLabel.setText(currentSession.summary.isBlank()?"Complete":currentSession.summary);
            return;
        }
        stage=0; playerRevealIndex=0; dealerRevealIndex=0; splitARevealIndex=0; splitBRevealIndex=0; openingStreamRevealCount=0; clearTable();
        // 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);
        handLabel.setText(String.format("HAND %02d / %02d", h.number,currentSession.hands.size()));
        bankrollLabel.setText("BANKROLL £"+money(h.researchStart()));
        wagerLabel.setText("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);
        previousPanel.showMatches(sessions,currentSession,handIndex,h,false);
        shoePanel.setSource(currentSession);
        shoePanel.setPolicyStatus(h);
        shoePanel.setStreak(currentSession, handIndex);
        if(h.shuffleBefore){shoePanel.showShuffle(h.shufflePreviousCards,h.number);actionLabel.setText("VISIBLE SHUFFLE RECORDED — shuffling before Hand "+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);
        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);
            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(h.actions.isEmpty()?"Recorded player action complete":String.join("  →  ",h.actions));
                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);
                    dealerCards.revalidate(); dealerCards.repaint();
                    dealerRevealIndex++; refreshTotals(h);
                    previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                    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) stage=6;
                    return;
                }
                if (h.playerBust) { stage=6; return; }
                dealerRevealIndex++;
                addCard(dealerCards,c,false); refreshTotals(h);
                previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                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);
                if(!check.isBlank()) actionLabel.setText(check);
            }
            // Split hands already carry their two child verdicts beneath the child cards.
            // Only ordinary hands use the single bottom settlement verdict.
            resultLabel.setText(h.split ? " " : resultText(h.result));
            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" :
            "⚠ DISPLAY / BANKROLL INCONSISTENCY — rendered settlement " + signedMoney(implied) + " vs recorded " + 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 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(friendlyOutcome(h.result)).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.198-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";}

    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;
        splitCards.add(splitPanel("SPLIT A • TOTAL " + totalText(ad), ad, h.splitAResult, compactBoth));
        splitCards.add(splitPanel("SPLIT B • TOTAL " + totalText(bd), bd, h.splitBResult, compactBoth));
        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 JPanel splitPanel(String title,List<String> cards,String outcome,boolean compactCards) {
        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.
        for(String c:cards) {
            if(compactCards) {
                CompactCardPanel cc=new CompactCardPanel(splitDisplayCard(c));
                Dimension d=new Dimension(50,68); 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) {
        if(h.playerBust) return "BUST — LOSS";
        if(h.natural && h.result.toUpperCase().startsWith("W")) return "BLACKJACK — WIN";
        if(h.dealerBust && h.result.toUpperCase().startsWith("W")) return "DEALER BUST — WIN";
        return friendlyOutcome(h.result);
    }

    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)); panel.revalidate(); panel.repaint(); }
    private String resultText(String r) {
        if(r.startsWith("W")) return "✓  WIN   •   " + r;
        if(r.startsWith("L")) return "✕  LOSS   •   " + r;
        if(r.startsWith("P")) return "—  PUSH   •   " + 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 String totalText(List<String> cards) {
        if(cards==null || cards.isEmpty()) return "--";
        int total=0, aces=0; boolean unknown=false;
        for(String c:cards){
            if(c==null || c.contains("?") || c.toUpperCase().contains("UNKNOWN")){unknown=true;continue;}
            String r=c.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim().toUpperCase();
            if(r.startsWith("A")){total+=11;aces++;}
            else if(r.startsWith("K")||r.startsWith("Q")||r.startsWith("J")||r.startsWith("10")) total+=10;
            else { Matcher m=Pattern.compile("([2-9])").matcher(r); if(m.find()) total+=Integer.parseInt(m.group(1)); else unknown=true; }
        }
        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;
        void setValues(double bankroll,double bet){this.bankroll=Math.max(0,bankroll);this.bet=Math.max(0,bet);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);
            int laneY = Math.max(250, (int)(getHeight() * 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);
        }
    }

    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";
        ShoePanel(){setOpaque(false);setBorder(new EmptyBorder(18,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";repaint();}
        void setSource(Session s){sourceLabel=(s!=null&&s.realCasino)?"DECK COUNT UNKNOWN":"6-DECK SOURCE";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 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; // a push breaks the W/L streak
                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();}
        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,17));center(g,"RECORDED SHOE",w/2,24);g.setColor(new Color(205,230,220));g.setFont(new Font("SansSerif",0,12));center(g,"Historical replay",w/2,44);
            int x=22,y=90,sw=Math.max(145,w-44);g.setColor(new Color(25,31,36));g.fillRoundRect(x,y,sw,255,22,22);g.setColor(new Color(80,90,95));g.setStroke(new BasicStroke(3));g.drawRoundRect(x,y,sw,255,22,22);g.setColor(new Color(215,225,228));g.fillRoundRect(x+20,y+25,sw-40,165,15,15);
            for(int i=0;i<14;i++){int yy=y+42+i*7;g.setColor(new Color(24,62,105));g.fillRoundRect(x+40,yy,sw-80,68,8,8);g.setColor(Color.WHITE);g.drawRoundRect(x+40,yy,sw-80,68,8,8);}g.setColor(TABLE);g.fillRoundRect(x+18,y+205,sw-36,34,10,10);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,12));center(g,sourceLabel,w/2,y+227);
            if(shuffling){g.setColor(GOLD);g.setFont(new Font("SansSerif",Font.BOLD,18));center(g,"SHUFFLE OBSERVED",w/2,y+300);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",0,12));center(g,"before Hand "+hand,w/2,y+321);if(previousCards>0)center(g,previousCards+" cards in prior segment",w/2,y+340);int center=w/2; int spread=Math.min(62, 10 + shuffleFrame*9);
                for(int i=0;i<8;i++){
                    boolean left=(i%2==0); int lane=i/2;
                    int cx=center-27 + (left?-spread:spread) + (left?lane*8:-lane*8);
                    int wave=(int)Math.round(Math.sin((shuffleFrame+i)*0.9)*10);
                    int cy=y+365 + wave + lane*4;
                    g.setColor(new Color(24,62,105));g.fillRoundRect(cx,cy,54,76,7,7);
                    g.setColor(Color.WHITE);g.drawRoundRect(cx,cy,54,76,7,7);
                    for(int d=0;d<3;d++) g.drawOval(cx+10+d*12,cy+12,4,4);
                }
                g.setColor(new Color(205,230,220));g.setFont(new Font("SansSerif",Font.BOLD,11));center(g,shuffleFrame<4?"PACKETS SEPARATING":"PACKETS INTERLEAVING",w/2,y+462);}
            else {g.setColor(new Color(220,235,228));g.setFont(new Font("SansSerif",Font.BOLD,14));center(g,hand>0?"DEALING • HAND "+hand:"WAITING",w/2,y+300);g.setFont(new Font("SansSerif",0,10));g.setColor(new Color(190,215,205));center(g,"Only recorded shuffles are shown",w/2,y+324);}
            int sy=y+390;
            g.setFont(new Font("SansSerif",Font.BOLD,11));
            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+24);
            g.setColor(new Color(220,235,228));center(g,streakStatus,w/2,sy+48);
            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);}
    }
    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(10,10,10,10)));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;}
        void clear(){removeAll();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();
            JPanel countWrap=new JPanel(new FlowLayout(FlowLayout.RIGHT,0,0)); countWrap.setOpaque(false);
            countWrap.setMaximumSize(new Dimension(Integer.MAX_VALUE,78));
            countWrap.add(buildHandCountPanel(cur,hi));
            add(Box.createVerticalStrut(2)); add(countWrap); add(Box.createVerticalStrut(4));
            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=loadSameStateCorpusStats(cur,now);
            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();
        }
        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,76)); box.setMinimumSize(new Dimension(285,76));
            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,66));return outer;
        }

        class SameStateCorpusStats {
            String state; 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;
        }
        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(4,7,4,7)));
            JLabel title=lab("SAME-STATE ROBUSTNESS  •  CURRENT OBSERVED SESSION",9,Font.BOLD,NAVY);title.setAlignmentX(Component.CENTER_ALIGNMENT);outer.add(title);
            JLabel scope=new JLabel("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(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);
            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);
            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,118));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);
            JPanel topSpacer=new JPanel();topSpacer.setOpaque(false);topSpacer.setPreferredSize(new Dimension(92,1));top.add(topSpacer,BorderLayout.EAST);b.add(top,BorderLayout.NORTH);
            JPanel middle=new JPanel(new BorderLayout(6,0));middle.setOpaque(false);
            JPanel c=new JPanel(new FlowLayout(FlowLayout.LEFT,3,0));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();holeWrap.setOpaque(false);holeWrap.setLayout(new BoxLayout(holeWrap,BoxLayout.Y_AXIS));holeWrap.setPreferredSize(new Dimension(92,70));
            JLabel holeLabel=lab("HOLE CARD",7,Font.BOLD,NAVY);holeLabel.setAlignmentX(Component.CENTER_ALIGNMENT);holeWrap.add(holeLabel);
            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,2,0));pcards.setOpaque(false);pcards.add(lab("5 CARDS BEFORE",7,Font.BOLD,new Color(90,98,105)));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()?118:132));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);
            JPanel topSpacer=new JPanel();topSpacer.setOpaque(false);topSpacer.setPreferredSize(new Dimension(92,1));top.add(topSpacer,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();hole.setOpaque(false);hole.setLayout(new BoxLayout(hole,BoxLayout.Y_AXIS));hole.setPreferredSize(new Dimension(92,58));JLabel hl=lab("HOLE CARD",7,Font.BOLD,NAVY);hl.setAlignmentX(Component.CENTER_ALIGNMENT);hole.add(hl);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,88));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){
        String base=displaySessionLabel(s);
        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(30,42));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,25,37,5,5);g.setColor(new Color(120,125,130));g.drawRoundRect(2,2,25,37,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,10));g.drawString(a,4,13);g.setFont(new Font("Serif",Font.PLAIN,13));g.drawString(s.isEmpty()?"?":s,4,29);drawBucketMarker(g,r,26,12,10);if(hole){g.setColor(new Color(176,88,0));g.setStroke(new BasicStroke(2.2f));g.drawRoundRect(1,1,27,39,5,5);}g.dispose();}}

    private static class CardPanel extends JComponent {
        final String raw; final boolean back; final boolean hole;
        CardPanel(String raw,boolean back){this(raw,back,false);}
        CardPanel(String raw,boolean back,boolean hole){this.raw=raw==null?"?":raw.trim();this.back=back;this.hole=hole;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);} 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 void updateCompareAvailability(){
        String target=String.valueOf(compareTargetBox.getSelectedItem());
        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){return false;}
    }
    private void openSelectedComparison(){
        String target=String.valueOf(compareTargetBox.getSelectedItem());
        if("Hybrid".equalsIgnoreCase(target)) openHybridComparison(); else openCasualComparison();
    }
    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);}
    }
    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;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;}}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+" ================");if(a<0)throw new IOException("session block not found");int b=text.indexOf("================ END SESSION "+sid,a);if(b<0)b=text.length();String 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)")){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.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;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");while(ct(dealer)<17)dealer.add(sh.draw());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");while(ct(dealer)<17)dealer.add(sh.draw());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){
        if(loadedOutputText==null||sid==null)return "COMPARABLE DECISIONS: N/A";
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a<0)return "COMPARABLE DECISIONS: 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 DECISIONS  "+m.group(1)+"     ✓ MATCHED  "+m.group(2)+"     ✕ DIFFERED  "+m.group(3)+"     ALIGNMENT  "+m.group(4);
        return last==null?"COMPARABLE DECISIONS: 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){
        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){
        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";
        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);
        String valueStyle="color:#006B2D;font-weight:bold;";
        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.129-UAT: reconstructed replay action display shows only operative player actions.
    // A trailing policy STAND after one or more HIT draws is an internal terminal evaluation,
    // not an additional displayed action.  Keep a standalone STAND and all other decisions intact.
    static String reconstructedActionDisplay(List<String> actions){
        if(actions==null||actions.isEmpty())return "—";
        List<String> shown=new ArrayList<>(actions);
        if(shown.size()>1 && "STAND".equalsIgnoreCase(shown.get(shown.size()-1))){
            boolean priorHit=false;for(int i=0;i<shown.size()-1;i++)if("HIT".equalsIgnoreCase(shown.get(i))){priorHit=true;break;}
            if(priorHit)shown.remove(shown.size()-1);
        }
        return shown.isEmpty()?"—":String.join(" → ",shown);
    }

    // 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);setPreferredSize(new Dimension(100,58));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("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; CObserved observedSource; int idx; boolean casualComplete; boolean useFormalFrozen; 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(); JButton prevButton,nextButton; boolean finalSummaryShown=false;
        CompareWindow(Session f,List<CompareHand>ff,List<CompareHand>c,int start,boolean casualComplete){
            super("Frozen vs Casual — "+sessionDisplayTitle(f));
            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();
            try{observedSource=observed(loadedOutputText,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());

            JLabel h=new JLabel(casualComplete ?
                    "<html><div style='text-align:center'>SOURCE-VERIFIED JUXTAPOSED REPLAY<br>FROZEN vs FIXED-POLICY CASUAL  •  SAME CAPTURED OBSERVED STREAM</div></html>" :
                    "<html><div style='text-align:center'>SOURCE-SUPPORTED JUXTAPOSED REPLAY<br>FIXED-POLICY CASUAL 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));
            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=comparisonLastCount();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'>"+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>"); 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)));

            decision.setHorizontalAlignment(SwingConstants.CENTER); decision.setOpaque(true);
            decision.setFont(new Font("SansSerif",Font.BOLD,14)); decision.setBorder(new EmptyBorder(5,8,5,8));

            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);
            JPanel centerWrap=new JPanel(new BorderLayout());centerWrap.setBackground(new Color(238,240,242));centerWrap.add(body,BorderLayout.CENTER);centerWrap.add(metricsHost,BorderLayout.SOUTH);add(centerWrap,BorderLayout.CENTER);
            // v15.10.198-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<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;}
        boolean samePointAt(int i){
            if(i<0||i>=casual.size()||i>=frozenCount())return false;
            return useFormalFrozen?sameVisibleAllocationCompare(formalFrozen.get(i),casual.get(i)):sameVisibleAllocation(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)):observedHandEndPointer(frozen,frozen.hands.get(i).number);
        }
        ReplayStoryMetrics frozenMetricsAt(int i){return useFormalFrozen?compareMetrics(formalFrozen,i):observedMetrics(frozen,i);}

        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)); p.add(identityHeader(cas?"CASUAL":"FROZEN",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){frozenDealer.add(fh.dealerUp);frozenDealer.addAll(fh.dealerHidden);frozenPlayer.addAll(fh.player);frozenDealerSeq=frozenDealerSeq(fh);frozenPlayerSeq=frozenPlayerSeq(fh);}
            if(ch!=null){casualDealer.addAll(ch.dealer);casualPlayer.addAll(ch.player);}
            if(cas){
                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));
                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("FIXED-POLICY CASUAL",ch.branches,ch.branchSeq,other,null));}
                else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(ch.player),ch.player,frozenPlayer,ch.playerSeq));
                if(ch.branches.size()>1)p.add(splitActionResultBlock(ch.actions,ch.branches,compareSplitResultText(ch)));
                else p.add(actionResultBlock("Actions: "+reconstructedActionDisplay(ch.actions),"RESULT: "+friendlyOutcome(ch.result)));
            }else{
                if(ff==null&&fh==null){p.add(Box.createVerticalStrut(45));p.add(cl("JOURNEY ENDED",19,true));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));
                    if(ff.branches.size()>1)p.add(splitActionResultBlock(ff.actions,ff.branches,compareSplitResultText(ff)));
                    else p.add(actionResultBlock("Actions: "+reconstructedActionDisplay(ff.actions),"RESULT: "+friendlyOutcome(ff.result)));
                }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));
                    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));
                    if(fh.split){List<List<String>> actionBranches=new ArrayList<>();actionBranches.add(fh.splitA);actionBranches.add(fh.splitB);p.add(splitActionResultBlock(fh.actions,actionBranches,"RESULT: "+friendlyOutcome(fh.result)));}
                    else p.add(actionResultBlock("Actions: "+(fh.actions.isEmpty()?"—":String.join(" → ",fh.actions)),"RESULT: "+friendlyOutcome(fh.result)));
                }
            }
            p.revalidate(); p.repaint();
        }
        int comparisonLastCount(){
            // Casual summaries are evidence-bounded: never extend beyond the mutually supported hand range.
            return Math.min(frozenCount(),casual.size());
        }
        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+" CASUAL",(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;
        }
        void showCasualFinalSummary(){
            finalSummaryShown=true;left.removeAll();right.removeAll();left.add(casualSummarySide("FROZEN — START-HAND DECISION-POINT SUMMARY",true));right.add(casualSummarySide("FIXED-POLICY CASUAL — START-HAND DECISION-POINT SUMMARY",false));left.revalidate();left.repaint();right.revalidate();right.repaint();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 HANDS ONLY  •  cumulative comparison across "+comparisonLastCount()+" mutually supported hand positions  •  no evidence inferred beyond the Fixed-Policy Casual source boundary");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("FIXED-POLICY CASUAL",samePoint,sameWager,sameAction,streakHands,runF,runC,pos,driftBadge));
            }
            statsHost.revalidate();statsHost.repaint();
        }
        void showIt(){
            int last=comparisonLastCount();
            if(idx>=last){showCasualFinalSummary();return;}
            finalSummaryShown=false;prevButton.setEnabled(idx>0);nextButton.setEnabled(true);
            fill(left,useFormalFrozen?"FROZEN — FORMAL-START RECONSTRUCTED":"FROZEN — OBSERVED",idx,false); fill(right,"FIXED-POLICY CASUAL — RECONSTRUCTED",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();metricsHost.add(storyMetricsStrip(frozenMetricsAt(idx),compareMetrics(casual,idx),"FROZEN (Reference)","FIXED-POLICY CASUAL (Reconstructed)",new Color(228,238,251),new Color(242,233,248)),BorderLayout.CENTER);metricsHost.revalidate();metricsHost.repaint(); updateComparisonStats();
            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("✕ FIXED-POLICY CASUAL: "+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{
                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));
            if(f.branches.size()>1)p.add(splitActionResultBlock(f.actions,f.branches,compareSplitResultText(f)));
            else p.add(actionResultBlock("Actions: "+(f.actions.isEmpty()?"—":String.join(" → ",f.actions)),"RESULT: "+friendlyOutcome(f.result)));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)));
            if(h.split){List<List<String>> actionBranches=new ArrayList<>();actionBranches.add(h.splitA);actionBranches.add(h.splitB);String child="SPLIT A "+friendlyOutcome(h.splitAResult)+" / SPLIT B "+friendlyOutcome(h.splitBResult);String check=displaySettlementCheck(h);String rr="RESULT: "+friendlyOutcome(h.result)+"  •  "+child+(check.isBlank()?"":"  •  "+check);p.add(splitActionResultBlock(h.actions,actionBranches,rr));}
            else p.add(actionResultBlock("Actions: "+(h.actions.isEmpty()?"—":String.join(" → ",h.actions)),"RESULT: "+friendlyOutcome(h.result)));
            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=sameVisibleAllocation(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=sameVisibleAllocation(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=sameVisibleAllocation(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(sameVisibleAllocation(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));
            }
            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=""; int publicationNumber=0; double offset=0; boolean hadPreamble=false; boolean realCasino=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();
            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*=+(.*?)(?==+\\s*HAND\\s+"+(h.number+1)+"\\s*/\\s*30\\s*=+|$)");
                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(lc.contains("natural")) { h.natural=true; 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+([^\\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> 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;
        }
    }

    public static void main(String[] args){
        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 {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new BlackjackLiveGui().setVisible(true));
    }
}

class BlackjackLiveGuiUAT extends BlackjackLiveGuiOld {
    private static final String UI_VERSION = "15.10.141"; // release-integration companion; human-controller identity from .139 retained unchanged
    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("Declare scale before Hand 1", SwingConstants.CENTER);
    private final JLabel scaledWagerNote = new JLabel("No wager-size inference", 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;
    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);

    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);
            }
        });
        setTitle("Deterministic Blackjack — Live Graphical Companion • " + UI_VERSION);
        replaceVisibleVersionText(getContentPane());
        installStatsCornerButton();
        installPercentileWagerPanel();
        installScaledWagerPanel();
        installLiveHumanIdentity();
        rebalanceLiveTableAndTranscript();
        rebalanceHistoryMemoryPanels();
        installVisibleFrequencyPanel();
        // 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();
        super.submitTyped();
        // 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 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 refreshVisibleFrequencyCounts() {
        int high=0,low=0,neutral=0;
        java.util.List<String> visible=new ArrayList<>();
        if(previousHandPhysical!=null)visible.addAll(previousHandPhysical);
        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("");
    }

    @Override
    void appendBackend(String text) {
        super.appendBackend(text);
        sanitizeQuickInputForCardReview(text);
        updateDeclaredScaleFromBackend(text);
        // 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();
            });
            return;
        }

        // 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 = "";
            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 = "";
            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(){
        int w=48,h=48;int statsW=132;int right=24+statsW+12;int percentileW=240,scaleW=250,gap=8;
        int scaleX=Math.max(560,getLayeredPane().getWidth()-right-percentileW-gap-scaleW);
        int x=Math.max(500,scaleX-w-10);int y=10;
        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();
    }

    // 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 = 240;
        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(20, 69, 111));
        scaledWagerPanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(205, 224, 238), 1),
                BorderFactory.createEmptyBorder(3, 8, 3, 8)));
        JLabel title = new JLabel("DECLARED FROZEN WAGER SCALE", SwingConstants.CENTER);
        title.setFont(new Font("SansSerif", Font.BOLD, 10));
        title.setForeground(Color.WHITE);
        scaledWagerValues.setFont(new Font("SansSerif", Font.BOLD, 13));
        scaledWagerValues.setForeground(new Color(255, 220, 92));
        scaledWagerNote.setFont(new Font("SansSerif", Font.PLAIN, 8));
        scaledWagerNote.setForeground(new Color(238, 244, 249));
        JPanel middle=new JPanel(new BorderLayout()); middle.setOpaque(false);
        middle.add(scaledWagerValues,BorderLayout.CENTER); middle.add(scaledWagerNote,BorderLayout.SOUTH);
        scaledWagerPanel.add(title,BorderLayout.NORTH); scaledWagerPanel.add(middle,BorderLayout.CENTER);
        getLayeredPane().add(scaledWagerPanel,JLayeredPane.PALETTE_LAYER);
        repositionScaledWagerPanel();
    }

    private void repositionScaledWagerPanel() {
        if(scaledWagerPanel==null)return;
        int statsW=132, right=24+statsW+12, w=250, h=54;
        int percentileW=240, gap=8;
        int x=Math.max(560,getLayeredPane().getWidth()-right-percentileW-gap-w);
        scaledWagerPanel.setBounds(x,8,w,h);
        scaledWagerPanel.revalidate(); scaledWagerPanel.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)+"  •  research £100  •  scale 1:"+m.group(2));
        }
        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");
        }
    }

    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();
    }
    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");
    }
    @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=currentSinglePlayerCardCount();
        boolean wasSplit=splitActive;
        super.captureRecentPlayerExperience();
        if(recentJourney==null||recentJourney.isEmpty())return;
        int i=recentJourney.size()-1;
        String row=recentJourney.get(i);
        boolean won=row!=null && row.toUpperCase(Locale.ROOT).contains("WIN");
        String taken=(!won || wasSplit)?"—":(cards==3?"3":(cards>=4?"4+":"—"));
        if(row!=null&&!row.contains("PLAYER CARDS TAKEN")){
            recentJourney.set(i,row+" | PLAYER CARDS TAKEN "+taken);
            journeyBadge.showItems(recentJourney);
        }
        decorateJourneyCardCounts();
    }
    private void decorateJourneyCardCounts(){
        // Give the inherited Recent Player Experience strip a little more vertical
        // room, then add a dedicated fourth/bottom row: 3 / 4+ / —.
        Dimension pref=journeyBadge.getPreferredSize();
        int width=(pref==null||pref.width<=0)?1000:pref.width;
        journeyBadge.setPreferredSize(new Dimension(width,70));
        journeyBadge.setMinimumSize(new Dimension(0,70));
        journeyBadge.setMaximumSize(new Dimension(Integer.MAX_VALUE,70));
        // Small left-side key so the bottom-row values are self-explanatory in Live play.
        for(Component c:journeyBadge.getComponents()){
            if(c instanceof JLabel){
                JLabel l=(JLabel)c;
                String t=l.getText();
                if(t!=null && t.toUpperCase(Locale.ROOT).contains("RECENT PLAYER EXPERIENCE")){
                    l.setText("<html><b>RECENT PLAYER EXPERIENCE</b><br><br><br><span style='font-size:8px'>NUMBER PLAYER CARDS ON WIN</span></html>");
                    l.setToolTipText("Bottom row shows player-card count on wins only: 3, 4+, or — otherwise");
                    break;
                }
            }
        }
        int rowIndex=0;
        for(Component c:journeyBadge.getComponents()){
            if(!(c instanceof JPanel))continue;
            if(rowIndex>=recentJourney.size())break;
            String src=recentJourney.get(rowIndex++);
            String taken="—";
            int p=src==null?-1:src.lastIndexOf("PLAYER CARDS TAKEN ");
            if(p>=0)taken=src.substring(p+19).trim();
            JPanel hand=(JPanel)c;
            // Remove any prior card-count row before re-decoration.
            for(int j=hand.getComponentCount()-1;j>=3;j--)hand.remove(j);
            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(cardsTaken);
        }
        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) l.setText(t.replace("15.10.15-UAT", UI_VERSION).replace("15.10.39", "15.10.42").replace("15.10.40", "15.10.42"));
        } 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));
    }
}
