Ship borealis to the browser, and add the classic three-round mode

The web client now plays borealis (data/models.json), trained on the three-round
match and taking the 501-dim match view rather than a bare round. Only the actor
trunk is exported -- the critic exists to grade moves in training and never plays,
so the graph physically cannot leak the opponent's hand or the deck, which beats
promising not to call it.

The TypeScript match layer and observation mirror match.py and match_obs.py. They
have to agree to the bit: a mismatch throws nowhere, the ONNX policy just consumes
a wrong vector and plays worse for reasons nobody can see. So the port is not
trusted -- generate_match_parity_fixture.py emits 282 positions from real JAX play
(mid-round, both seats, past a roll-over, with a live carry) and the TS output is
checked against them to float32 round-off.

Match mode is a menu toggle. A seed fixes all three deals and the coin flips, so a
match stays a pure function of it. One-deal mode is unchanged from the player's
side; borealis simply sees it as round one at a carry of zero, a position it has
seen a great many times.

Two bugs found by driving the built app in a browser, both silent:

- The result card totalled the round, not the match. It read "-11 : 3" while the
  match stood at -96 : 66 -- it would have named the wrong winner. It now headlines
  the match total and breaks the round out beneath it.
- Game records were being rejected. The client's schema went to v2 (it now records
  which model played; the old records stored the on-screen label, which stops
  identifying anything once there are two models) while serve_web_with_logs.py
  still only accepted v1, so every record would have 400'd into a console warning.
  v1 stays accepted -- the 111 existing games are altair.

npm test and tsc were green through both. Hence web/.claude/skills/verify, which
records the recipe and the selectors so the next session drives the app instead of
re-deriving how.

.gitignore excluded the new model, which would have shipped a 404: the deploy
builds straight from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
This commit is contained in:
2026-07-15 07:06:29 +09:00
co-authored by Claude Opus 4.8
parent 8dec6c3fe1
commit 1d3b29aadb
22 changed files with 1438 additions and 101 deletions
+93 -15
View File
@@ -472,7 +472,7 @@ button.card:disabled { cursor: default; }
.deck-stack > span { margin-top: 5px; color: #7e817c; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; }
.deck-stack.is-draw-target .card { box-shadow: 0 0 0 3px var(--gold), 7px 8px 0 #171b1e, 0 0 32px rgba(201, 163, 75, 0.3); }
/* Insets keep the row clear of the history bar parked at the right. */
/* Insets keep the prompt clear of the controls parked at the right. */
.prompt-row {
position: absolute;
right: 330px;
@@ -498,7 +498,6 @@ button.card:disabled { cursor: default; }
.turn-prompt--review { color: #9fb4c8; }
.hint-button {
flex: 0 0 auto;
height: 32px;
padding: 0 14px;
border: 1px solid var(--gold-dim);
@@ -610,23 +609,25 @@ button.card:disabled { cursor: default; }
.result-card button:hover { border-color: var(--gold); }
.result-card button.is-primary { border-color: var(--gold); color: var(--gold); }
/* Sits in the band above the hand: the hand row is centered and its width is
computed against the score plaques, so a wide bar down at the hand's level
would overlap the leftmost/rightmost cards. */
.history-bar {
/* Player controls share the hand row's bottom edge and stay in the mirrored
right-side keep-out reserved by the centered hand layout. */
.control-stack {
position: absolute;
right: 24px;
bottom: 196px;
bottom: 30px;
z-index: 12;
display: flex;
align-items: center;
width: 142px;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.history-bar button {
.control-stack button {
height: 32px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--gold-dim);
border-radius: 9px;
@@ -634,15 +635,17 @@ button.card:disabled { cursor: default; }
font: 700 13px/1 Georgia, serif;
cursor: pointer;
}
.history-bar button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.history-bar button:hover:not(:disabled) { border-color: var(--gold); }
.history-bar button:disabled { opacity: 0.3; cursor: default; }
.history-bar__resume {
.control-stack button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.control-stack button:hover:not(:disabled) { border-color: var(--gold); }
.control-stack button:disabled { opacity: 0.3; cursor: default; }
.control-stack__resume {
border-color: var(--gold) !important;
color: var(--gold);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.14em;
}
.control-stack__score { visibility: hidden; }
.control-stack__score.is-available { visibility: visible; }
@media (max-width: 1250px) {
.table-center { width: 600px; }
@@ -651,7 +654,6 @@ button.card:disabled { cursor: default; }
.human-hand .card { width: 98px; height: 142px; margin-left: 7px; }
.human-hand { height: 146px; }
.prompt-row { bottom: 177px; }
.history-bar { bottom: 172px; }
.table-center { bottom: 210px; }
/* Narrow viewports: the plaques are the keep-out that squeezes the hand row,
so they become compact chips (model-name line dropped) to give the cards
@@ -675,7 +677,83 @@ button.card:disabled { cursor: default; }
.human-hand { bottom: 18px; height: 138px; }
.human-hand .card { width: 94px; height: 134px; }
.prompt-row { bottom: 165px; }
.history-bar { bottom: 160px; }
.control-stack { bottom: 18px; }
.score-plaque--human { bottom: 9px; }
.deck-stack { transform: scale(0.86); }
}
/* Match mode: which round is live, and what is already banked. The board only
ever shows the round in play, so without this the score plaques would be the
only hint that two more rounds are coming. */
.round-strip {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(18, 14, 10, 0.72);
border: 1px solid rgba(214, 188, 140, 0.22);
font-size: 11px;
letter-spacing: 0.12em;
color: rgba(214, 188, 140, 0.55);
z-index: 4;
pointer-events: none;
}
.round-strip span {
font-weight: 700;
}
.round-strip span.is-done {
color: rgba(214, 188, 140, 0.85);
}
.round-strip span.is-live {
color: #f0d9a6;
text-shadow: 0 0 10px rgba(240, 217, 166, 0.45);
}
.round-strip em {
font-style: normal;
margin-left: 4px;
padding-left: 10px;
border-left: 1px solid rgba(214, 188, 140, 0.22);
color: rgba(214, 188, 140, 0.75);
}
.menu-modes {
display: flex;
gap: 6px;
}
.menu-modes button {
flex: 1;
font-size: 10px;
}
.menu-modes button.is-active {
background: rgba(240, 217, 166, 0.16);
color: #f0d9a6;
border-color: rgba(240, 217, 166, 0.45);
}
/* A match's headline is the summed total; the table still breaks down the round
in front of you, so the two have to be told apart. */
.result-card__banked {
display: block;
margin-top: 6px;
font-size: 11px;
letter-spacing: 0.1em;
color: rgba(214, 188, 140, 0.55);
}
.result-table__match td,
.result-table__match th {
border-top: 1px solid rgba(214, 188, 140, 0.22);
color: #f0d9a6;
font-weight: 700;
}