Six of seven perceptual defects fixed; item 1 already passed. T01, at the maintainer's instruction: a legal target restyles its EXISTING border rather than drawing a new box. outline + outline-offset drew a second rectangle, which an SVG viewport clips (the missing top and left edges) and which made a seat's highlight card-sized. A border already in the layout cannot move the layout. T02: the ghost was a textContent copy of the card, which is why the line break collapsed and it read as a second card, and why showing the explanation destroyed the label. It is now a pill, the explanation is appended beside the label, and the left-behind element is dimmed and dashed. The stub grew innerHTML so a test can assert BOTH are present -- it could previously only see that something was displayed. T03: NOT reproduced and recorded as not reproduced. The likeliest cause is which element the browser reports -- for touch and pen the pointer is captured to the pointerdown target, making every drop look like a drop-on-itself, which is the other half of the report. elementFromPoint is correct under both explanations. Separately the refusal was written in element ids on the one surface a player reads when something goes wrong; it now speaks the game's words and a test forbids id leakage. T04: seat selections rendered as Debug. The coverage gate then failed my first fix for dropping a field when target and problem were both set -- the aggregate does not produce that shape and the gate was right not to care. T05: the headline reads from group_success. 'Play again' is real, and its first version was useless: run_game bound a fresh listener per game, so a second game moved to a new port and left the tab pointing at a dead one. One listener per session now, and the test asserts the second game is a DIFFERENT deal. Chaos d8=8 fired the first override at the new rate and drew S, changing nothing -- one half of window 2's retirement condition. CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight, and the first under 20%. make all exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5e06a7d01e
commit
bf24affa84
8 changed files with 584 additions and 55 deletions
|
|
@ -58,8 +58,21 @@ pub const SCRIPT: &str = r#"
|
|||
(function () {
|
||||
var down = null, held = null, marked = [], ghost = null, ghostLabel = '';
|
||||
|
||||
// What is actually under the pointer, not what the browser decided the
|
||||
// event belongs to. CB-WP-0020 T03: for touch and pen a browser
|
||||
// implicitly captures the pointer to the pointerdown target, so
|
||||
// `e.target` on pointerup can be the element you STARTED on wherever
|
||||
// you release. That makes a drop look like a drop-on-itself, and the
|
||||
// "nothing droppable" message unreachable. elementFromPoint is correct
|
||||
// under both behaviours.
|
||||
function under(e) {
|
||||
if (document.elementFromPoint && e.clientX !== undefined) {
|
||||
return document.elementFromPoint(e.clientX, e.clientY) || e.target;
|
||||
}
|
||||
return e.target;
|
||||
}
|
||||
function node(e) {
|
||||
var n = e.target;
|
||||
var n = under(e);
|
||||
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
|
||||
return n;
|
||||
}
|
||||
|
|
@ -116,8 +129,13 @@ pub const SCRIPT: &str = r#"
|
|||
if (n.getAttribute('data-targets')) {
|
||||
ghost = document.createElement('div');
|
||||
ghost.id = 'cb-ghost';
|
||||
ghostLabel = n.textContent;
|
||||
ghost.textContent = ghostLabel;
|
||||
// Keep the label as markup, so the explanation can be appended
|
||||
// rather than replacing it (CB-WP-0020 T02). The first version set
|
||||
// textContent, which collapsed the card's line break and then got
|
||||
// overwritten by the explanation -- losing the only sign of what
|
||||
// was being carried, exactly when it was needed.
|
||||
ghostLabel = (n.getAttribute('data-drop') || '').replace('action-', '');
|
||||
ghost.innerHTML = '<b>' + ghostLabel + '</b>';
|
||||
ghost.style.left = e.clientX + 'px';
|
||||
ghost.style.top = e.clientY + 'px';
|
||||
document.body.appendChild(ghost);
|
||||
|
|
@ -131,7 +149,8 @@ pub const SCRIPT: &str = r#"
|
|||
// The explanation, beside the pointer and therefore beside the
|
||||
// target it is over (CB-WP-0018 T03).
|
||||
var over = describeOf(held, key(node(e)));
|
||||
ghost.textContent = over || ghostLabel;
|
||||
ghost.innerHTML = '<b>' + ghostLabel + '</b>'
|
||||
+ (over ? '<span class="why">' + over + '</span>' : '');
|
||||
ghost.className = over ? 'over' : '';
|
||||
});
|
||||
|
||||
|
|
@ -177,19 +196,35 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
|
|||
.pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset;
|
||||
transition:transform .08s,box-shadow .08s}
|
||||
.pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)}
|
||||
/* Held: the thing in your hand is lifted and dimmed where it used to be. */
|
||||
.held{cursor:grabbing;opacity:.45;transform:scale(.97)}
|
||||
/* A legal destination for the thing currently held -- and ONLY for that
|
||||
thing. The set is written by Rust into data-targets; the script matches
|
||||
it and never derives it (ADR-0010 D1). */
|
||||
.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33}
|
||||
it and never derives it (ADR-0010 D1).
|
||||
|
||||
CB-WP-0020 T01, at the maintainer's instruction: change the EXISTING
|
||||
border, do not draw a new box. `outline` + `outline-offset` drew a
|
||||
second rectangle outside the element, which an SVG viewport clips (the
|
||||
reported missing top and left edges) and which made a seat's highlight
|
||||
the size of a whole card. Restyling the border in place cannot move
|
||||
anything, because the border is already in the layout. */
|
||||
.dropok{border-style:dashed;border-color:#9cf;background:#1d2a33}
|
||||
.dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3}
|
||||
.dropok text{fill:#cfe}
|
||||
/* The ghost that follows the pointer, so a drag is not invisible. */
|
||||
#cb-ghost.over{background:#1d3347;border-color:#9cf;color:#cfe}
|
||||
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem;
|
||||
border-radius:6px;background:#243;border:1px solid #5a7;color:#dde;
|
||||
font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b;
|
||||
transform:translate(-50%,-140%)}
|
||||
/* The thing in your hand. Deliberately NOT card-shaped: it used to be a
|
||||
textContent copy of the card, so the line break collapsed and it read
|
||||
as a second card sitting next to the first (CB-WP-0020 T02). */
|
||||
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.25rem .55rem;
|
||||
border-radius:999px;background:#2b4a3a;border:1px solid #7ca;
|
||||
color:#dfe;font:12px ui-monospace,monospace;
|
||||
box-shadow:0 6px 16px #000b;transform:translate(-50%,-160%);
|
||||
white-space:nowrap}
|
||||
/* The explanation is ADDITIONAL, never a replacement: losing the label is
|
||||
losing the only sign of what you are carrying. */
|
||||
#cb-ghost b{color:#cfe}
|
||||
#cb-ghost .why{color:#9cf;margin-left:.4rem}
|
||||
/* What you picked up, left visibly behind so there are not two cards. */
|
||||
.held{cursor:grabbing;opacity:.35;border-style:dashed}
|
||||
.k{color:#89a}
|
||||
.nil{color:#c88}
|
||||
.eff{color:#8c9}
|
||||
|
|
@ -325,6 +360,28 @@ fn relations_svg(view: &GroundView) -> String {
|
|||
s
|
||||
}
|
||||
|
||||
/// A revealed selection, phrased the way the log phrases the command.
|
||||
///
|
||||
/// Deliberately the same shape as `event_line`'s `ActionSelected` arm —
|
||||
/// a second vocabulary for the same fact drifts from the first, which is
|
||||
/// exactly what `{:?}` was doing here.
|
||||
fn selection_words(s: &games_ground::Selection) -> String {
|
||||
// Every field that is set is named. The first draft matched on
|
||||
// `(target, problem)` and showed only the target when both were
|
||||
// present — the coverage gate caught it, because a field the document
|
||||
// never shows is a field a player never sees. The aggregate does not
|
||||
// currently produce both, but a renderer that silently drops one is
|
||||
// the omission class this crate exists to guard against.
|
||||
let mut out = format!("{:?}", s.action);
|
||||
if let Some(t) = s.target {
|
||||
let _ = write!(out, " on {}", seat_name(t));
|
||||
}
|
||||
if let Some(n) = s.problem {
|
||||
let _ = write!(out, " for problem {n}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
|
||||
let is_viewer = view.viewer == Some(id);
|
||||
let _ = write!(
|
||||
|
|
@ -394,7 +451,12 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
|
|||
"<span class=\"k\">selected</span> {}<br>",
|
||||
match sel {
|
||||
SelectionView::Hidden => "face down".to_string(),
|
||||
SelectionView::Shown(s) => esc(&format!("{s:?}")),
|
||||
// CB-WP-0020 T04: in words, not `Selection { action:
|
||||
// Solve, target: None, problem: Some(1) }`. After Reveal
|
||||
// this is how a player follows what everyone else did,
|
||||
// and it was the same Debug-on-a-player-surface defect
|
||||
// the log fixed in CB-WP-0018 and this did not.
|
||||
SelectionView::Shown(s) => esc(&selection_words(s)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -758,7 +820,10 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L
|
|||
log_section(&mut s, log);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div class=\"card btn pick\" data-drop=\"done\">close \u{2014} I have read this</div>\
|
||||
"<div class=\"row\">\
|
||||
<div class=\"card btn pick\" data-drop=\"again\">play again</div>\
|
||||
<div class=\"card btn pick\" data-drop=\"done\">close \u{2014} I have read this</div>\
|
||||
</div>\
|
||||
<div id=\"cb-status\">the game is over</div>\
|
||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
|
||||
endpoint = json_string(endpoint),
|
||||
|
|
|
|||
|
|
@ -130,6 +130,49 @@ pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Say why a drop meant nothing, in words a player can act on.
|
||||
///
|
||||
/// Deliberately does **not** consult the rules to explain *why* a move is
|
||||
/// illegal — that would be a second implementation of them. It names what
|
||||
/// was dropped on what, and leaves the reason to the log.
|
||||
fn refusal(fact: &PointerFact, seat: cb_kernel::PlayerId) -> String {
|
||||
let name = |id: &str| -> String {
|
||||
if let Some(a) = id.strip_prefix("action-") {
|
||||
let mut c = a.chars();
|
||||
return match c.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
None => id.to_string(),
|
||||
};
|
||||
}
|
||||
if let Some(n) = id.strip_prefix("seat-") {
|
||||
return n
|
||||
.parse::<u8>()
|
||||
.map(|n| format!("P{}", n + 1))
|
||||
.unwrap_or_else(|_| id.to_string());
|
||||
}
|
||||
if let Some(n) = id.strip_prefix("problem-") {
|
||||
return format!("problem {n}");
|
||||
}
|
||||
match id {
|
||||
"table" => "the table".to_string(),
|
||||
_ if id.starts_with("freedom-") => "your freedom token".to_string(),
|
||||
_ => id.to_string(),
|
||||
}
|
||||
};
|
||||
if fact.down == fact.up {
|
||||
return format!("{} needs to be dropped on something", name(&fact.down));
|
||||
}
|
||||
let you = format!("P{}", seat.0 + 1);
|
||||
if fact.up == format!("seat-{}", seat.0) {
|
||||
return format!("{} cannot be aimed at {you} right now", name(&fact.down));
|
||||
}
|
||||
format!(
|
||||
"{} on {} is not a move you can make right now",
|
||||
name(&fact.down),
|
||||
name(&fact.up)
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a pointer fact to an index into the legal list.
|
||||
///
|
||||
/// Returns `Err` rather than a default when nothing matches: a drag that
|
||||
|
|
@ -169,10 +212,12 @@ pub fn resolve(
|
|||
"{} -> {} is offered by more than one legal command; the page is ambiguous",
|
||||
fact.down, fact.up
|
||||
)),
|
||||
(None, _) => Err(format!(
|
||||
"{} -> {} is not a legal move here",
|
||||
fact.down, fact.up
|
||||
)),
|
||||
// CB-WP-0020 T03: written in the game's words, not the DOM's.
|
||||
// This read `action-attack -> action-attack is not a legal move
|
||||
// here`, which is the vocabulary of element ids — the same defect
|
||||
// the log had before CB-WP-0018, on the surface a player actually
|
||||
// reads when something goes wrong.
|
||||
(None, _) => Err(refusal(fact, seat)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,11 @@ function __mk(key, targets, text, descs) {
|
|||
var n = {
|
||||
parentNode: null,
|
||||
textContent: text || key,
|
||||
// `innerHTML` is stored and its text extracted, so a test can assert
|
||||
// that the label and the explanation COEXIST (CB-WP-0020 T02) rather
|
||||
// than only that something is displayed.
|
||||
set innerHTML(v) { this._html = v; this.textContent = String(v).replace(/<[^>]*>/g, ''); },
|
||||
get innerHTML() { return this._html || ''; },
|
||||
style: {},
|
||||
_cls: {},
|
||||
_attr: { 'data-drop': key, 'data-targets': targets || null,
|
||||
|
|
@ -590,13 +595,30 @@ mod tests {
|
|||
ctx.eval("__down('action-attack'); __moveOver('seat-1');")
|
||||
.expect("drag over");
|
||||
let shown: String = ctx.eval_as("__ghostText()").expect("ghost text");
|
||||
assert_eq!(shown, want);
|
||||
|
||||
// The explanation is ADDITIONAL, never a replacement. The first
|
||||
// version replaced the ghost's label with the description, which
|
||||
// is what the maintainer reported: *"replacing the content of the
|
||||
// picked up card breaks the visual clue about a card being
|
||||
// moved"*. Both must be present at once.
|
||||
assert!(shown.contains(&want), "no explanation: {shown:?}");
|
||||
assert!(
|
||||
shown.to_lowercase().contains("attack"),
|
||||
"the ghost stopped naming what is held: {shown:?}"
|
||||
);
|
||||
|
||||
// Over nothing droppable it falls back to the label, rather than
|
||||
// keeping a stale explanation pointing at the wrong element.
|
||||
ctx.eval("__move();").expect("move away");
|
||||
let away: String = ctx.eval_as("__ghostText()").expect("ghost text");
|
||||
assert_ne!(away, want, "the explanation stuck after leaving the target");
|
||||
assert!(
|
||||
!away.contains(&want),
|
||||
"the explanation stuck after leaving the target: {away:?}"
|
||||
);
|
||||
assert!(
|
||||
away.to_lowercase().contains("attack"),
|
||||
"the label vanished when the explanation did: {away:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every advertised target has a description; a card offering three
|
||||
|
|
|
|||
|
|
@ -150,9 +150,12 @@ mod coverage {
|
|||
("problems.*.protected_this_round", "protected"),
|
||||
("focus.*", "\u{2192}P3"),
|
||||
("selections.*.state", "face down"),
|
||||
("selections.*.action", "action: Attack"),
|
||||
("selections.*.target", "target: Some(PlayerId(1))"),
|
||||
("selections.*.problem", "problem: Some(7)"),
|
||||
// CB-WP-0020 T04: words, not Debug. These tokens were
|
||||
// `action: Attack` / `target: Some(PlayerId(1))` /
|
||||
// `problem: Some(7)` — the shape a player was being shown.
|
||||
("selections.*.action", "selected Attack"),
|
||||
("selections.*.target", "Attack on P2"),
|
||||
("selections.*.problem", "for problem 7"),
|
||||
("ground_modes.*", "ground mode Gr"),
|
||||
("ground_choices.*.choice", "ProtectProblem"),
|
||||
("ground_choices.*.problem", "problem: 7 }"),
|
||||
|
|
|
|||
152
evidence/CB-EV-0018-the-table-you-can-read.md
Normal file
152
evidence/CB-EV-0018-the-table-you-can-read.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# CB-EV-0018 — four human checks, four sets of defects no test could reach
|
||||
|
||||
CB-WP-0020 T06. Measured 2026-08-03 at `5e06a7d`+. Pass kind `product`,
|
||||
tier **S** (structural S; **chaos d8 = 8 → OVERRIDE, drawn S**).
|
||||
Declaration 3 of chaos window 2.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the check found, and what the suite said about it
|
||||
|
||||
Seven items. **Item 1 passed** — the resting affordance reads without
|
||||
touching anything. Every other one was a defect, and `make all` was green
|
||||
for all of them.
|
||||
|
||||
| # | reported | cause |
|
||||
|---|---|---|
|
||||
| 2 | *"as if we have two cards now"*, the line break vanishes | the ghost was a `textContent` copy of the card |
|
||||
| 3 | *"upper and left borders … hidden"*, seat highlight *"quite large"* | `outline` + `outline-offset` drew a **second** rectangle; an SVG viewport clips it |
|
||||
| 4 | *"replacing the content … breaks the visual clue"* | the explanation **replaced** the ghost's label |
|
||||
| 5 | status line *"switches only if i put back a card"* | see §3 — not reproduced, and fixed anyway |
|
||||
| 6 | *"cant follow the actions of the other players"* | seat cards rendered `Selection { action: Solve, … }` — `Debug` on a player surface |
|
||||
| 7 | *"game over"* wrong when the group succeeded; no way to play again | the ending was written for one outcome and one game |
|
||||
|
||||
### Could any of them have been caught mechanically?
|
||||
|
||||
Honestly, **two of the seven**, and neither by a check that existed:
|
||||
|
||||
- **#6** is the same `{:?}`-on-a-player-surface defect the log fixed in
|
||||
CB-WP-0018, in a place that pass did not look. A grep for `{:?}` in
|
||||
emitted output would find it, and that check does not exist. **Worth
|
||||
building.**
|
||||
- **#7's headline** is a branch nobody wrote a case for; a test asserting
|
||||
the ending page differs on `group_success` would have caught it.
|
||||
|
||||
**The other five cannot be caught here and saying otherwise would be the
|
||||
error this project keeps naming.** #2, #3 and #4 are about what a layout
|
||||
*looks like*; #5 is about which element a browser reports under a pointer.
|
||||
QuickJS has no layout engine and no hit-testing, and a stub that grew one
|
||||
would be asserting against my model of a browser rather than a browser.
|
||||
|
||||
## 2. The border moves, nothing else does
|
||||
|
||||
Adopted as instructed: a legal target restyles its **existing** border
|
||||
rather than drawing a new box.
|
||||
|
||||
```
|
||||
.dropok{border-style:dashed;border-color:#9cf;background:#1d2a33}
|
||||
.dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3}
|
||||
```
|
||||
|
||||
That is the whole fix for the missing top and left edges — an `outline` on
|
||||
an SVG `<g>` is clipped by the viewport, and on a card it collided with
|
||||
the neighbour's margin. It also removes the oversized seat highlight,
|
||||
because the border was always the right size. **A border already in the
|
||||
layout cannot move the layout**, which is what *"more stable and less
|
||||
complicated"* was asking for.
|
||||
|
||||
## 3. The report I could not reproduce, and what I did instead
|
||||
|
||||
Item 5 said the *"nothing droppable"* message never appears. The harness
|
||||
test for it passes, the page has real gaps to drop into, and I have no
|
||||
browser — so I could not reproduce it, and **the task said not to fix a
|
||||
message that already works.**
|
||||
|
||||
The likeliest explanation is not the message but **which element the
|
||||
browser reports**: for touch and pen a browser implicitly captures the
|
||||
pointer to the `pointerdown` target, so `e.target` on `pointerup` is the
|
||||
element you *started* on wherever you release. That would make every drop
|
||||
look like a drop-on-itself — which is exactly the other half of the
|
||||
report, *"switches only if i put back a card, then showing `action-attack
|
||||
-> action-attack`"*.
|
||||
|
||||
So the fix is to stop asking the event and ask the document:
|
||||
`document.elementFromPoint(clientX, clientY)`. **That is correct under
|
||||
both explanations** — with a mouse it returns the same element `e.target`
|
||||
would; with capture it returns the truth instead of the capture target.
|
||||
|
||||
**Recorded as unreproduced, not as diagnosed.** If item 5 still misbehaves
|
||||
after this, the cause is something else and this note is the starting
|
||||
point.
|
||||
|
||||
**And the refusal was written in element ids.** `action-attack ->
|
||||
action-attack is not a legal move here` is the vocabulary of the DOM on
|
||||
the one surface a player reads when something goes wrong. It now says
|
||||
*"Attack needs to be dropped on something"* or *"Attack on P2 is not a
|
||||
move you can make right now"*, and a test asserts no `action-` id leaks
|
||||
into it.
|
||||
|
||||
## 4. Two defects my own tests found while fixing these
|
||||
|
||||
**The selection renderer dropped a field.** `selection_words` matched on
|
||||
`(target, problem)` and showed only the target when both were set. The
|
||||
42-path coverage gate failed the build: *"claimed rendered, but absent
|
||||
from the emitted document: selections.\*.problem"*. The aggregate does not
|
||||
currently produce both — but a renderer that silently drops one is the
|
||||
omission class this crate exists to guard against, and the gate does not
|
||||
care whether the case is reachable today.
|
||||
|
||||
**"Play again" moved the game to a new port.** The first version worked
|
||||
end to end and would have been useless: `run_game` bound a fresh listener
|
||||
per game, so dealing a second game left the player's tab pointing at a
|
||||
dead port — the CB-WP-0018 defect returning by a different route. The
|
||||
listener is now bound once per **session**.
|
||||
|
||||
`play_again_deals_a_second_game` catches it, and it asserts the second
|
||||
game is a **different deal**, not merely that a second game happened — a
|
||||
seed that did not advance would have satisfied a naive count.
|
||||
|
||||
## 5. Chaos: the first override at d8, and it changed nothing
|
||||
|
||||
`d8 = 8` fired on the **second roll** at the new rate and drew **S**,
|
||||
which is what the structural derivation already said.
|
||||
|
||||
**That is one half of window 2's retirement condition** — *retire if an
|
||||
override changes nothing twice running*. **One.** If the next override
|
||||
also changes nothing, the mechanism goes.
|
||||
|
||||
Worth stating plainly so it is not mistaken for evidence either way: an
|
||||
override firing on roll 2 of 12 at a 1-in-8 rate is unremarkable luck, and
|
||||
drawing the structural tier is a 1-in-3 outcome. Neither number says
|
||||
anything about whether d8 was the right rate. **The window needs its
|
||||
twelve.**
|
||||
|
||||
## 6. Cost
|
||||
|
||||
| pass | kind | responses | cost | $/response |
|
||||
|---|---|---|---|---|
|
||||
| **CB-WP-0019** | meta | 117 | **$38.54** | 0.329 |
|
||||
| CB-WP-0020 | product | *provisional — not quoted* | | |
|
||||
|
||||
Read by re-running `make status` at the moment of writing, per the rule
|
||||
CB-WP-0019 wrote. CB-WP-0019 was last reported at $34.80/107 and has
|
||||
settled at **$38.54/117** — **eight for eight**, and the first one under
|
||||
20%, which is what re-running at quote time is supposed to do.
|
||||
|
||||
**Meta budget: back under the line** — CB-WP-0020 is product work, which
|
||||
is what the 27% breach called for.
|
||||
|
||||
## 7. Open
|
||||
|
||||
- **The human check is four for four.** Every run has found something the
|
||||
suite called green. Its cost is one maintainer session per pass; its
|
||||
yield so far is a broken drag, a lying affordance list, an invisible
|
||||
ending, and this set. **It should stay a gate on stage 1.**
|
||||
- **A `{:?}`-in-emitted-output check** would have caught item 6 and does
|
||||
not exist. Cheap; owed.
|
||||
- **The engine still has not imported the edition data**, now that
|
||||
GROUND-WP-0002 T01 has ruled it authoritative. That is the next product
|
||||
pass, and it is what makes the ending's `0` scores and `winners nobody`
|
||||
meaningful — **confirmed as the stand-in's doing, not a scoring bug**.
|
||||
- **`ground-game` owes ten rulings** (U1–U10) and SOLVE's legality.
|
||||
- **Chaos: 3 of 12 in window 2, one override, changed nothing.**
|
||||
|
|
@ -200,14 +200,14 @@ impl Server {
|
|||
final_view: Option<&games_ground::view::GroundView>,
|
||||
message: &str,
|
||||
linger: std::time::Duration,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<EndChoice, String> {
|
||||
let deadline = std::time::Instant::now() + linger;
|
||||
self.listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| format!("nonblocking: {e}"))?;
|
||||
let outcome = loop {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break Ok(());
|
||||
break Ok(EndChoice::Closed);
|
||||
}
|
||||
let (mut stream, _) = match self.listener.accept() {
|
||||
Ok(pair) => pair,
|
||||
|
|
@ -241,8 +241,21 @@ impl Server {
|
|||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||
}
|
||||
("POST", "/command") => {
|
||||
respond(&mut stream, 200, "text/plain", "closed");
|
||||
break Ok(());
|
||||
// CB-WP-0020 T05: the browser could not start a second
|
||||
// game without going back to a terminal, which made it
|
||||
// a strictly worse client than the CLI it replaces.
|
||||
let again = req.body.contains("again");
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/plain",
|
||||
if again { "ok: dealing" } else { "closed" },
|
||||
);
|
||||
break Ok(if again {
|
||||
EndChoice::Again
|
||||
} else {
|
||||
EndChoice::Closed
|
||||
});
|
||||
}
|
||||
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
|
||||
}
|
||||
|
|
@ -252,6 +265,13 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the player asked for on the ending page.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EndChoice {
|
||||
Closed,
|
||||
Again,
|
||||
}
|
||||
|
||||
/// One seat's view of the shared server.
|
||||
pub struct SeatPolicy {
|
||||
server: Rc<Server>,
|
||||
|
|
@ -471,6 +491,64 @@ mod tests {
|
|||
assert!(replies[1].contains("closed"), "{}", replies[1]);
|
||||
}
|
||||
|
||||
/// **CB-WP-0020 T05.** "play again" must deal a second game, not just
|
||||
/// answer politely and exit — the browser could not start a second
|
||||
/// game without going back to a terminal, which made it a strictly
|
||||
/// worse client than the CLI it replaces.
|
||||
#[test]
|
||||
fn play_again_deals_a_second_game() {
|
||||
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let out = SharedOut(buf.clone());
|
||||
let game = std::thread::spawn(move || {
|
||||
crate::table::play(
|
||||
&crate::table::Config {
|
||||
seed: 3,
|
||||
players: 3,
|
||||
human_seats: vec![0],
|
||||
bot: "random".into(),
|
||||
replay_dir: None,
|
||||
record: None,
|
||||
serve: Some(0),
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
)
|
||||
});
|
||||
let (port, token) = wait_for_url(&buf);
|
||||
|
||||
// Play one game out, ask for another, play that one out too.
|
||||
let mut deals = 0;
|
||||
for round in 0..2 {
|
||||
drive_to_end(port, &token);
|
||||
deals += 1;
|
||||
let want = if round == 0 { "again" } else { "done" };
|
||||
let body = format!("down={want}&up={want}");
|
||||
let reply = http(
|
||||
port,
|
||||
&format!(
|
||||
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
||||
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
),
|
||||
);
|
||||
if round == 0 {
|
||||
assert!(reply.contains("dealing"), "play again refused: {reply}");
|
||||
}
|
||||
}
|
||||
assert_eq!(deals, 2, "the second game was never dealt");
|
||||
game.join().expect("game thread").expect("the games ran");
|
||||
|
||||
// Two games, and they must not be the same deal — "again" that
|
||||
// re-dealt the identical game would satisfy a naive count.
|
||||
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
|
||||
let hashes: Vec<&str> = text
|
||||
.match_indices("game over \u{2014} ")
|
||||
.map(|(i, _)| &text[i..i + 60])
|
||||
.collect();
|
||||
assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len());
|
||||
assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game");
|
||||
}
|
||||
|
||||
/// A game that ended badly must say so rather than draw a table for a
|
||||
/// game that never happened.
|
||||
#[test]
|
||||
|
|
@ -501,6 +579,70 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Block until the server prints its URL, then return (port, token).
|
||||
fn wait_for_url(buf: &std::sync::Arc<std::sync::Mutex<Vec<u8>>>) -> (u16, String) {
|
||||
loop {
|
||||
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
|
||||
if let Some(i) = text.find("http://127.0.0.1:") {
|
||||
let rest = &text[i..];
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
let url = &rest[..end];
|
||||
let port = url["http://127.0.0.1:".len()..]
|
||||
.split('/')
|
||||
.next()
|
||||
.expect("port")
|
||||
.parse()
|
||||
.expect("port number");
|
||||
return (port, url.split("t=").nth(1).expect("token").to_string());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the offered moves until the page stops offering any, which is
|
||||
/// the ending. Returns the last page.
|
||||
fn drive_to_end(port: u16, token: &str) -> String {
|
||||
let mut last = String::new();
|
||||
for _ in 0..60 {
|
||||
last = http(
|
||||
port,
|
||||
&format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
||||
);
|
||||
let keys = cb_render_html::jsrun::droppables(&last);
|
||||
let spatial = keys
|
||||
.iter()
|
||||
.find(|(k, t)| k.starts_with("action-") && t.is_some())
|
||||
.map(|(k, t)| {
|
||||
(
|
||||
k.clone(),
|
||||
t.as_ref()
|
||||
.expect("checked")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("t")
|
||||
.to_string(),
|
||||
)
|
||||
});
|
||||
let button = keys
|
||||
.iter()
|
||||
.find(|(k, _)| k.starts_with("cmd-") || k == "pass")
|
||||
.map(|(k, _)| (k.clone(), k.clone()));
|
||||
let Some((down, up)) = spatial.or(button) else {
|
||||
return last;
|
||||
};
|
||||
let body = format!("down={down}&up={up}");
|
||||
http(
|
||||
port,
|
||||
&format!(
|
||||
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
|
||||
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
fn http(port: u16, raw: &str) -> String {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||
s.write_all(raw.as_bytes()).expect("write");
|
||||
|
|
@ -776,9 +918,19 @@ mod tests {
|
|||
|
||||
let replies = client.join().expect("client thread");
|
||||
assert!(replies[0].contains("200 OK"));
|
||||
// CB-WP-0020 T03: the refusal is in the game's words now, not the
|
||||
// DOM's. It read `action-attack -> action-attack is not a legal
|
||||
// move here` on the surface a player reads when something goes
|
||||
// wrong — element ids, the same defect the log had.
|
||||
assert!(
|
||||
replies[0].contains("not a legal move"),
|
||||
"a meaningless drag must be told it meant nothing: {}",
|
||||
replies[0].contains("not a move you can make")
|
||||
|| replies[0].contains("needs to be dropped on"),
|
||||
"a meaningless drag must be told it meant nothing, in words: {}",
|
||||
replies[0]
|
||||
);
|
||||
assert!(
|
||||
!replies[0].contains("action-"),
|
||||
"the refusal leaked an element id: {}",
|
||||
replies[0]
|
||||
);
|
||||
assert!(replies[1].contains("200 OK"));
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use games_ground::{GroundCommand, GroundState};
|
|||
use crate::inspect::{render, seat_name};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub seed: u64,
|
||||
pub players: u8,
|
||||
|
|
@ -220,14 +221,39 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
|
|||
// borrow them, so ownership stays here and the game runs one frame in.
|
||||
let input = std::cell::RefCell::new(input);
|
||||
let out = std::cell::RefCell::new(out);
|
||||
run_game(config, &input, &out)
|
||||
|
||||
// CB-WP-0020 T05: a browser session can deal a second game. The seed
|
||||
// advances, because replaying the identical deal is not "again".
|
||||
// CB-WP-0020 T05: ONE listener for the session, not one per game.
|
||||
// The first version bound a fresh port inside `run_game`, so "play
|
||||
// again" moved the game to a new URL and the player's tab was left
|
||||
// pointing at a dead port. Caught by `play_again_deals_a_second_game`.
|
||||
let server = match config.serve {
|
||||
Some(port) => {
|
||||
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
|
||||
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
|
||||
let _ = out.borrow_mut().flush();
|
||||
Some(s)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut cfg: Config = config.clone();
|
||||
loop {
|
||||
let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?;
|
||||
if choice != crate::hotseat::EndChoice::Again {
|
||||
return Ok(summary);
|
||||
}
|
||||
cfg.seed = cfg.seed.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
||||
config: &Config,
|
||||
input: &'a std::cell::RefCell<R>,
|
||||
out: &'a std::cell::RefCell<W>,
|
||||
) -> Result<Summary, String> {
|
||||
server: Option<std::rc::Rc<crate::hotseat::Server>>,
|
||||
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
|
||||
let setup = Setup {
|
||||
players: config.players,
|
||||
preset: format!("standard-{}p", config.players),
|
||||
|
|
@ -243,15 +269,6 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
// ADR-0007: a browser seat and a CLI seat are both just a Policy, so
|
||||
// the driver cannot tell them apart — which is the property that lets
|
||||
// a browser game replay as a scenario like any other.
|
||||
let server = match config.serve {
|
||||
Some(port) => {
|
||||
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
|
||||
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
|
||||
let _ = out.borrow_mut().flush();
|
||||
Some(s)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
|
||||
for seat in 0..config.players {
|
||||
if config.human_seats.contains(&seat) {
|
||||
|
|
@ -322,10 +339,11 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
// CB-WP-0018 T01: the browser sees the end of its own game. Measured
|
||||
// before this: a game ended at 5 rounds / 30 commands, the result went
|
||||
// to stdout, and the page's post-`ok` reload got Connection refused.
|
||||
let mut end_choice = crate::hotseat::EndChoice::Closed;
|
||||
if let Some(srv) = &server {
|
||||
let ended = game.state.project(Viewer::Spectator);
|
||||
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
|
||||
let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
|
||||
end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?;
|
||||
}
|
||||
|
||||
let scenario = games_ground::record::to_scenario(
|
||||
|
|
@ -361,11 +379,14 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
}
|
||||
};
|
||||
|
||||
Ok(Summary {
|
||||
rounds: game.rounds,
|
||||
end_state_hash: end_hash,
|
||||
scenario,
|
||||
bundle,
|
||||
recorded,
|
||||
})
|
||||
Ok((
|
||||
Summary {
|
||||
rounds: game.rounds,
|
||||
end_state_hash: end_hash,
|
||||
scenario,
|
||||
bundle,
|
||||
recorded,
|
||||
},
|
||||
end_choice,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
id: CB-WP-0020
|
||||
kind: product
|
||||
title: "The table you can read: the card in your hand, the border that moves, the game that ends"
|
||||
status: ready
|
||||
status: done
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
|
@ -47,7 +47,7 @@ Quoted, because the wording carries the diagnosis:
|
|||
|
||||
```task
|
||||
id: CB-WP-0020-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -69,11 +69,16 @@ when only the border needed to change.
|
|||
reading them must stay red. This is a style change and must not become a
|
||||
behaviour change.
|
||||
|
||||
**Done 2026-08-03.** `.dropok` restyles `border-style`/`border-color` in
|
||||
place, and SVG targets change their existing `stroke` with a dash array.
|
||||
No `outline` remains. **A border already in the layout cannot move the
|
||||
layout**, which is what the instruction was for.
|
||||
|
||||
## Task: the thing in your hand looks like a thing in your hand
|
||||
|
||||
```task
|
||||
id: CB-WP-0020-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -95,11 +100,20 @@ that pair (ADR-0010 D1) — the neighbouring-pair mutation stays red. And a
|
|||
new one: the ghost must still name what is held when an explanation is
|
||||
showing, so a mutation that drops the label goes red.
|
||||
|
||||
**Done 2026-08-03.** The ghost is a pill, not a card — it cannot be
|
||||
mistaken for a second card — and the explanation is appended beside the
|
||||
label rather than replacing it. The left-behind element is dimmed to 0.35
|
||||
and dashed, so it reads as *left behind*.
|
||||
|
||||
The stub grew `innerHTML` so the test can assert **both** are present at
|
||||
once; it previously could only see that *something* was displayed, which
|
||||
is why the replacement went unnoticed.
|
||||
|
||||
## Task: say why a drop was refused, in words
|
||||
|
||||
```task
|
||||
id: CB-WP-0020-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -120,11 +134,26 @@ every part of the page being a card, in which case the message is correct
|
|||
and unreachable, and the fix is elsewhere. **Do not "fix" a message that
|
||||
already works; reproduce the report first.**
|
||||
|
||||
**Done 2026-08-03. Not reproduced, and recorded as not reproduced.** The
|
||||
harness test passes and the page has real gaps; without a browser I cannot
|
||||
see it fail.
|
||||
|
||||
The likeliest cause is not the message but **which element the browser
|
||||
reports**: for touch and pen the pointer is implicitly captured to the
|
||||
`pointerdown` target, so `e.target` on `pointerup` is where you *started*.
|
||||
That would make every drop look like a drop-on-itself — which is the other
|
||||
half of the report. `document.elementFromPoint` is now used instead, and
|
||||
it is **correct under both explanations**.
|
||||
|
||||
**The refusal is in the game's words**, not element ids: *"Attack on P2 is
|
||||
not a move you can make right now"*. A test asserts no `action-` id leaks
|
||||
into it.
|
||||
|
||||
## Task: show what the other players did
|
||||
|
||||
```task
|
||||
id: CB-WP-0020-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
|
|
@ -141,11 +170,19 @@ do not re-phrase: a second vocabulary drifts from the first.
|
|||
**Control:** a seat's rendered selection must match the log's phrasing for
|
||||
the same command, asserted rather than eyeballed.
|
||||
|
||||
**Done 2026-08-03.** `selection_words` renders every field that is set.
|
||||
|
||||
**The coverage gate failed my first version**, which matched on
|
||||
`(target, problem)` and showed only the target when both were present:
|
||||
*"claimed rendered, but absent: selections.\*.problem"*. The aggregate does
|
||||
not currently produce both — the gate does not care whether the case is
|
||||
reachable, and it was right not to.
|
||||
|
||||
## Task: the game ends in a way you can act on
|
||||
|
||||
```task
|
||||
id: CB-WP-0020-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
|
|
@ -163,11 +200,25 @@ Three things, in the order they matter:
|
|||
the edition data authoritative. **Do not fix scoring here.** Confirm the
|
||||
cause and record it; the import is its own pass.
|
||||
|
||||
**Done 2026-08-03.** The headline reads from the outcome: *"the group held
|
||||
the frame"* / *"did not hold the frame"*, and only says *"game over"* when
|
||||
there is no outcome to report.
|
||||
|
||||
**"Play again" is real, and its first version was useless.** `run_game`
|
||||
bound a fresh listener per game, so a second game moved to a new port and
|
||||
left the player's tab pointing at a dead one — the CB-WP-0018 defect
|
||||
returning by another route. The listener is bound once per **session**
|
||||
now. `play_again_deals_a_second_game` asserts the second game is a
|
||||
**different deal**, not merely that one happened.
|
||||
|
||||
**The 0 scores and `winners nobody` are the stand-in dataset**, confirmed,
|
||||
not a scoring bug. Not fixed here.
|
||||
|
||||
## Task: evidence
|
||||
|
||||
```task
|
||||
id: CB-WP-0020-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -183,3 +234,21 @@ priority: high
|
|||
is one half of window 2's retirement condition; record it as such.
|
||||
- **Quote CB-WP-0019's cost by re-running the instrument**, per the rule
|
||||
it wrote.
|
||||
|
||||
**Done 2026-08-03.**
|
||||
[CB-EV-0018](../evidence/CB-EV-0018-the-table-you-can-read.md). `make all`
|
||||
exits 0.
|
||||
|
||||
- **Four human checks, four sets of defects the suite called green.**
|
||||
Of these seven, **two** could have been caught mechanically and neither
|
||||
by a check that exists; the other five are about layout and hit-testing
|
||||
and cannot be reached from here. Saying otherwise would be the error
|
||||
this project keeps naming.
|
||||
- **Two defects found by this project's own tests** while fixing the
|
||||
reported ones: a renderer dropping a field (coverage gate) and "play
|
||||
again" moving the game to a dead port.
|
||||
- **Chaos: the first override at d8 changed nothing** — one half of
|
||||
window 2's retirement condition.
|
||||
- **CB-WP-0019 settled at $38.54/117** against $34.80/107 last reported.
|
||||
**Eight for eight**, and the first under 20% — which is what re-running
|
||||
at quote time is for.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue