Some checks failed
ci / check (push) Failing after 3s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
14 KiB
Markdown
330 lines
14 KiB
Markdown
---
|
|
id: CB-WP-0018
|
|
kind: product
|
|
title: "The browser is a client: game over, a log, and where a drop goes"
|
|
status: done
|
|
state_hub_workstream_id: "8a87aaec-1fd1-4faa-87d9-e10220adf45c"
|
|
---
|
|
|
|
# Purpose
|
|
|
|
```
|
|
structural tier M (changes how the loop constrains its own operation:
|
|
the chaos rate d4 → d8 and a second calibration
|
|
window, owed by CB-EV-0015 §5)
|
|
chaos d4 = 3 → no override
|
|
declared tier M
|
|
```
|
|
|
|
**Declaration 1 of the second chaos window** — opened by T04 below, at the
|
|
rate T04 sets. This declaration was rolled at the old d4, because the rate
|
|
changes when the decision lands, not retroactively.
|
|
|
|
## The defect: a finished game is indistinguishable from a crash
|
|
|
|
Reported: *"after some time i get an empty page back. I guess the game
|
|
crashes or ends but that is unclear as the ui disappears."*
|
|
|
|
Reproduced by driving a real game to completion over HTTP:
|
|
|
|
```
|
|
move 5 accepted → ok
|
|
GET / → URLError: [Errno 111] Connection refused
|
|
```
|
|
|
|
The game **ended normally** — 5 rounds, 30 commands — and everything it
|
|
produced went to the terminal:
|
|
|
|
```
|
|
coalition [P2] score 0
|
|
coalition [P3] score 0
|
|
winners
|
|
game over — 30 commands, hash f6c890a65271
|
|
```
|
|
|
|
The browser got nothing, because `next_choice` only accepts connections
|
|
*inside* a human decision point. When `play()` returns, `run_game` writes
|
|
the outcome to stdout and the process exits; the listener dies and the
|
|
page's post-`ok` reload is refused.
|
|
|
|
**The browser is a second-class client.** Every terminal outcome — the
|
|
result, the scores, the winners, and every error `run_game` can return —
|
|
is invisible to the only interface a player is actually using. A crash and
|
|
a win render identically: nothing.
|
|
|
|
This is the same defect class as CB-WP-0016's silent drop. The system
|
|
refuses to say what happened and the player is left to infer it.
|
|
|
|
## Task: serve the end of the game
|
|
|
|
```task
|
|
id: CB-WP-0018-T01
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "5654aae5-dc87-4621-8061-6492f626f1c1"
|
|
```
|
|
|
|
When `play()` returns — **`Ok` or `Err`** — the browser must be told, on
|
|
the page, in terms a player understands.
|
|
|
|
- **A win** shows the outcome the spectator projection already carries:
|
|
totals, threshold, coalitions, mastery, winners.
|
|
- **An error** shows that the game ended badly and why. `run_game`
|
|
currently returns `Err(String)` to a terminal nobody is reading.
|
|
- The final page must **not** auto-reload into a refused connection.
|
|
|
|
The listener has to outlive the game, which it does not today. Decide how
|
|
it ends — a timeout, an explicit close, or serving until interrupted — and
|
|
say why; a server that never exits is its own defect.
|
|
|
|
**Controls:** a test that plays a game to its end through the socket and
|
|
asserts the final GET returns a page naming the outcome, **not** a refused
|
|
connection. And the error path, driven by a game that fails, asserting the
|
|
reason reaches the page.
|
|
|
|
**Done 2026-08-03.** `Server::serve_end` plus `doc::ending`, wired into
|
|
both of `run_game`'s exits. Verified live: where the browser used to get
|
|
`Connection refused` it now gets an 8,998-byte page reading *"GROUND —
|
|
game over … 30 commands, hash f6c890a65271"* with the final table.
|
|
|
|
**How it ends:** it serves until the page posts `done` — the ending page
|
|
carries a *"close — I have read this"* control — with a 600 s linger as
|
|
the bound, so an abandoned tab cannot hold the process open and a player
|
|
reading the result is not raced by a timeout.
|
|
|
|
`document()` was split into `body()` and `move_section()` so the ending
|
|
shows the **same** table rather than a second rendering of it; two
|
|
renderings of one state is how they drift.
|
|
|
|
**The control had to be built twice, and the first one was worthless.**
|
|
`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly —
|
|
and deleting the call from `run_game` left it **green**. It tested the
|
|
link and not the chain, which is CB-EV-0012's finding recurring
|
|
(*"every link was tested and the chain was not"*).
|
|
|
|
`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real
|
|
`play()` with a browser seat, drives a real game to its end over a real
|
|
socket, and requires the last page to be the ending. Under the same
|
|
mutation it goes red — and prints an **empty page**, which is exactly the
|
|
symptom that was reported.
|
|
|
|
A second control: a game that ended badly says why and draws **no** table,
|
|
because drawing a table for a game that never happened is the same lie the
|
|
empty page told.
|
|
|
|
**And a weak assertion of mine, caught by itself.** The first version
|
|
grepped the page for `location.reload`. The ending page reuses `SCRIPT`,
|
|
whose reload is guarded by `t.indexOf('ok') === 0`, and the ending
|
|
endpoint answers `closed` — so the grep would have forced a second script
|
|
to satisfy a test rather than a requirement. That is the source-text
|
|
control shape ADR-0010 D2 demoted. It now asserts the endpoint cannot
|
|
answer `ok`.
|
|
|
|
## The second report, and what it actually is
|
|
|
|
Reported: *"the cards I play by pulling them on a target will not be
|
|
removed, that is wrong i guess we will need a discard pile."*
|
|
|
|
**A discard pile already exists** — `solution_discard` on `GroundState`,
|
|
`SolutionDiscarded` removes the card from the hand and pushes it there,
|
|
and the page already renders `deck N remaining / discard …`. Building one
|
|
would have been building a thing that is there.
|
|
|
|
What the maintainer dragged were **action** cards. The five GROUND actions
|
|
are not cards and are correctly never consumed. Solution cards leave the
|
|
hand at **Resolve**, not at Select, because a selection is a face-down
|
|
commit.
|
|
|
|
But the report is pointing at something real. Measured over a live game:
|
|
|
|
| move | hand | discard |
|
|
|---|---|---|
|
|
| 1 · Investigate → problem-2 | 2 cards | none |
|
|
| 2 · Investigate → problem-3 | **3 cards** | none |
|
|
| 3 · Solve → problem-1 | 4 cards | none |
|
|
| 4 · Solve → problem-1 | **4 cards** | none |
|
|
| 5 · Solve → problem-1 | **4 cards** | none |
|
|
|
|
Investigate draws, correctly. **Solve was played three times and did
|
|
nothing, three times, in silence.** GR-A02's resolver `continue`s when the
|
|
problem is face-down, already claimed, denied, or when the seat holds no
|
|
card of the matching suit — and `legal_commands` offers Solve on every
|
|
face-up problem without consulting the hand, while the page offers it on
|
|
problem-1 which was still face-down.
|
|
|
|
**Whether that is a rule gap or intended is not this repo's call.** A
|
|
face-down commit you cannot fulfil is a plausible bluff in a commit/reveal
|
|
game with DARVO, and INTENT defers game semantics to `ground-game`. What
|
|
*is* this repo's call is that a move which provably does nothing is
|
|
offered, accepted, and never accounted for. That is the log's job (T02)
|
|
and the explanation's job (T03), and it is now the concrete case both are
|
|
measured against.
|
|
|
|
Raised for `ground-game`, not decided here: **should SOLVE be selectable
|
|
against a face-down problem, or against a suit the seat cannot match?**
|
|
|
|
## Task: a game log, and what to do next
|
|
|
|
```task
|
|
id: CB-WP-0018-T02
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "1ed3e87a-2a21-4b08-9a3a-2bbf69268dec"
|
|
```
|
|
|
|
Requested: *"a game log about the events that have been generated and a
|
|
hint of what the next move/options are."*
|
|
|
|
The events exist — `validate` produces them and `record::to_scenario`
|
|
already turns them into a scenario file. The page shows none of them, so a
|
|
player sees state change with no account of why.
|
|
|
|
- **The log** is the event stream, in the recorder's vocabulary. Do not
|
|
invent a fourth phrasing: `cb-play`'s `describe()` exists precisely so
|
|
*"what the player reads is what the scenario file will say"*, and the
|
|
HTML renderer currently uses `{c:?}` Debug instead. Reuse it or say why
|
|
it cannot be reused.
|
|
- **The hint** is what the seat may do now, which is `legal` — already in
|
|
hand, already rendered as cards by CB-WP-0017. The hint is the *step*
|
|
context that makes those cards make sense: what the round is waiting
|
|
for, and what happens when it stops waiting.
|
|
|
|
**Control:** the log must be derived from the events the aggregate
|
|
produced, not re-narrated from the state. A log that describes the state
|
|
after the fact is a second implementation of the rules and will drift.
|
|
|
|
**The case it must handle**, from the measurement above: three SOLVEs that
|
|
produced no events at all. A log built only from events would show nothing
|
|
for them and reproduce the silence. So the log must distinguish *"this
|
|
command produced these events"* from *"this command produced none"* —
|
|
the second is the one the player needs and the harder one to render.
|
|
|
|
**Done 2026-08-03.** `bot::Journal` — a shared list of `Applied { actor,
|
|
command, events }` the driver appends to, via the new `play_journaled`.
|
|
`play` delegates to it with `None`, so nothing existing changed. The
|
|
journal exists because `BotGame.events` only appears *after* `play`
|
|
returns, which is no use to a page rendered mid-game.
|
|
|
|
The log is phrased with `record::to_step` — the recorder's vocabulary, so
|
|
what the player reads is what the scenario file will say — and every one
|
|
of the 29 `GroundEvent` variants now renders in words rather than `{:?}`.
|
|
From a live game:
|
|
|
|
```
|
|
P1 select_action action=INVESTIGATE problem=2
|
|
→ P1 chose Investigate on problem 2
|
|
the round resolve
|
|
→ problem 2 turned face up
|
|
→ P1 drew a solution
|
|
→ step → Resolve
|
|
the round end_round
|
|
→ round 2 ended; P2 leads next
|
|
```
|
|
|
|
**A command that produced no events says `no effect`**, which is the whole
|
|
point; the mutation that drops that branch goes red.
|
|
|
|
**An honest limitation.** The reported case — SOLVE doing nothing — is
|
|
*not* rendered as `no effect`, because the SOLVE is resolved inside the
|
|
system's `resolve` command, which does produce events for other seats. The
|
|
player now sees the selection and sees no claim follow it, which is a
|
|
large improvement on silence but is still an inference. Making it explicit
|
|
would mean the renderer deciding *why* a rule did nothing, which is a
|
|
second implementation of the rules — exactly what this task's control
|
|
forbids. Left as an inference deliberately, and owed to `ground-game` as
|
|
the question of whether the move should be offered at all.
|
|
|
|
## Task: say where a drop goes, and what it means
|
|
|
|
```task
|
|
id: CB-WP-0018-T03
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "f603476c-8fc3-46c4-9897-361d243cec5d"
|
|
```
|
|
|
|
Requested: *"an overlay arrow pointing to the droptarget element with an
|
|
explanation showing up besides it, when we drag on it."*
|
|
|
|
CB-WP-0017 marks every legal target. What it does not do is say what any
|
|
particular drop **means** — the player sees five outlined boxes and must
|
|
still guess which one does what.
|
|
|
|
So the explanation is the substance and the arrow is the delivery. Both
|
|
need the meaning to exist as data first, and **ADR-0010 Decision 1 binds
|
|
here**: the description of what `action-attack → seat-1` does must be
|
|
written by Rust into the page. The script may render it on hover; it may
|
|
not compose it.
|
|
|
|
**Controls:** every advertised target carries a description, and the
|
|
description a target shows is the one Rust wrote for *that* pair — a
|
|
mutation that shows a neighbouring pair's text must go red. The set
|
|
equality property from ADR-0010 D2 must still hold.
|
|
|
|
**Done 2026-08-03.** `input::describe` writes a sentence per legal
|
|
command; `data-descs` carries them in step with `data-targets`; the ghost
|
|
already following the pointer shows the one for whatever legal target is
|
|
under it, so the explanation appears beside the target without an overlay
|
|
layer to keep aligned.
|
|
|
|
**Both mutations initially SURVIVED**, because the fixture's Attack card
|
|
had exactly **one** target — where an off-by-one shift and a truncation
|
|
are both no-ops. That is CB-EV-0014's lesson one level in: *a fixture too
|
|
thin to express a failure is how the failure survives*. The fixture now
|
|
offers two attack targets on purpose and both go red.
|
|
|
|
## Task: close the chaos change, and the evidence
|
|
|
|
```task
|
|
id: CB-WP-0018-T04
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "590f5eb7-b2c9-468d-8db7-4dede4e95371"
|
|
```
|
|
|
|
CB-EV-0015 §5 recommended and did not make the change, on the grounds that
|
|
it alters how the loop constrains its own operation and was therefore owed
|
|
a tier-M declaration. This is it.
|
|
|
|
**Make it or reject it**, in `specs/InnerLoop.md` and `gates.toml`:
|
|
|
|
- rate **d4 → d8**;
|
|
- a **second window of 12** declarations;
|
|
- new retirement condition: *retire if an override changes nothing twice
|
|
running*.
|
|
|
|
The argument to test rather than assume: both overrides were informative
|
|
*because they were rare*. If that is right the rate should fall; if the
|
|
real driver was that they landed on passes with something to find, the
|
|
rate is irrelevant and the recommendation is wrong. Say which, and note
|
|
that n=2 is thin evidence for a rate change — including the possibility
|
|
that the honest answer is to keep d4 for another window and decide with
|
|
four data points instead of two.
|
|
|
|
Then `evidence/CB-EV-0016-*.md`:
|
|
|
|
- **What the maintainer's three reports have cost and bought**, now that
|
|
there are three in a row and every one found something no test did.
|
|
- **Whether stage 1 closes.**
|
|
- **Quote CB-WP-0017's cost by re-running the instrument**, not from
|
|
memory — CB-EV-0015 §6 found that quoting a remembered figure defeats
|
|
the rule even when the boundary is right, and this is the first pass
|
|
that can apply that correction.
|
|
|
|
**Done 2026-08-03.**
|
|
[CB-EV-0016](../evidence/CB-EV-0016-the-browser-is-a-client.md). `make
|
|
all` exits 0.
|
|
|
|
- **Chaos rate d4 → d8, window 2 open at 12 declarations**, retiring if an
|
|
override changes nothing twice running. Window 1's condition was not
|
|
met — both overrides changed the outcome — so the mechanism is kept.
|
|
**The weakest part of the decision is that it is a rate change argued
|
|
from n=2**, and window 2 therefore carries a falsifier: no override at
|
|
all is evidence the rate went too far.
|
|
- The rationale moved to `InnerLoopReference.md` because `InnerLoop.md`
|
|
hit 401 lines and the loadability gate fired — fixed structurally, per
|
|
the standing precedent that limits are not raised.
|
|
- **CB-WP-0017 settled at $9.48/40** against $5.19/23 reported mid-flight,
|
|
83% higher. Six for six, always low. Read by re-running the instrument
|
|
at the moment of quoting, which is CB-EV-0015 §6's correction applied
|
|
for the first time.
|