Compare commits
5 commits
332d8f1bba
...
a733826d95
| Author | SHA1 | Date | |
|---|---|---|---|
| a733826d95 | |||
| 7a78c58404 | |||
| 57639623da | |||
| 78e82497ee | |||
| e8bb7263f2 |
12 changed files with 1397 additions and 47 deletions
|
|
@ -56,7 +56,7 @@ fn cards(list: &[games_ground::SolutionCard]) -> String {
|
|||
/// The only JavaScript in the project. See the module docs.
|
||||
pub const SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var down = null, held = null, marked = [], ghost = null;
|
||||
var down = null, held = null, marked = [], ghost = null, ghostLabel = '';
|
||||
|
||||
function node(e) {
|
||||
var n = e.target;
|
||||
|
|
@ -70,6 +70,19 @@ pub const SCRIPT: &str = r#"
|
|||
// wrote. This matches on them. It does not compute, infer, filter or
|
||||
// default one -- a script that pattern-matched ids to guess what is
|
||||
// legal would be forbidden even though the visible result is identical.
|
||||
// ADR-0010 D1 again: `data-descs` is written by Rust, in step with
|
||||
// `data-targets`. The script pairs them by index and renders one. It
|
||||
// does not compose a description from an id.
|
||||
function describeOf(held, key) {
|
||||
if (!held) { return null; }
|
||||
var t = held.getAttribute('data-targets');
|
||||
var d = held.getAttribute('data-descs');
|
||||
if (!t || !d) { return null; }
|
||||
var i = t.split(' ').indexOf(key);
|
||||
var all = d.split('|');
|
||||
return i >= 0 && i < all.length ? all[i] : null;
|
||||
}
|
||||
|
||||
function mark(n) {
|
||||
var spec = n.getAttribute('data-targets');
|
||||
if (!spec) { return; }
|
||||
|
|
@ -88,6 +101,7 @@ pub const SCRIPT: &str = r#"
|
|||
if (held) { held.classList.remove('held'); held = null; }
|
||||
if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); }
|
||||
ghost = null;
|
||||
ghostLabel = '';
|
||||
down = null;
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +116,8 @@ pub const SCRIPT: &str = r#"
|
|||
if (n.getAttribute('data-targets')) {
|
||||
ghost = document.createElement('div');
|
||||
ghost.id = 'cb-ghost';
|
||||
ghost.textContent = n.textContent;
|
||||
ghostLabel = n.textContent;
|
||||
ghost.textContent = ghostLabel;
|
||||
ghost.style.left = e.clientX + 'px';
|
||||
ghost.style.top = e.clientY + 'px';
|
||||
document.body.appendChild(ghost);
|
||||
|
|
@ -113,6 +128,11 @@ pub const SCRIPT: &str = r#"
|
|||
if (!ghost) { return; }
|
||||
ghost.style.left = e.clientX + 'px';
|
||||
ghost.style.top = e.clientY + 'px';
|
||||
// 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.className = over ? 'over' : '';
|
||||
});
|
||||
|
||||
document.addEventListener('pointercancel', clear);
|
||||
|
|
@ -165,11 +185,15 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
|
|||
.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33}
|
||||
.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%)}
|
||||
.k{color:#89a}
|
||||
.nil{color:#c88}
|
||||
.eff{color:#8c9}
|
||||
#cb-log{max-height:16rem;overflow-y:auto;font-size:13px}
|
||||
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
|
||||
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
|
||||
";
|
||||
|
|
@ -405,6 +429,18 @@ pub fn document(
|
|||
endpoint: &str,
|
||||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
) -> String {
|
||||
document_with_log(view, legal, endpoint, seat, may_pass, &[])
|
||||
}
|
||||
|
||||
/// The table, plus the game log (CB-WP-0018 T02).
|
||||
pub fn document_with_log(
|
||||
view: &GroundView,
|
||||
legal: &[games_ground::GroundCommand],
|
||||
endpoint: &str,
|
||||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
log: &[LogLine],
|
||||
) -> String {
|
||||
let mut s = String::with_capacity(8192);
|
||||
let _ = write!(
|
||||
|
|
@ -431,6 +467,25 @@ pub fn document(
|
|||
},
|
||||
);
|
||||
|
||||
body(&mut s, view);
|
||||
move_section(&mut s, legal, seat, may_pass);
|
||||
log_section(&mut s, log);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div id=\"cb-status\">ready</div>\
|
||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>\
|
||||
</body></html>",
|
||||
endpoint = json_string(endpoint),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// The table itself: problems, relationships, seats, solutions, outcome.
|
||||
///
|
||||
/// Factored out of [`document`] so [`ending`] shows the SAME table rather
|
||||
/// than a second rendering of it — two renderings of one state is how
|
||||
/// they drift.
|
||||
fn body(s: &mut String, view: &GroundView) {
|
||||
s.push_str(
|
||||
"<h2>problems</h2><svg width=\"760\" height=\"100\" role=\"img\" aria-label=\"problems\">",
|
||||
);
|
||||
|
|
@ -440,7 +495,7 @@ pub fn document(
|
|||
);
|
||||
}
|
||||
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
||||
problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130);
|
||||
problem_svg(s, *priority, p, 10 + (i as i32) * 130);
|
||||
}
|
||||
s.push_str("</svg>");
|
||||
|
||||
|
|
@ -449,7 +504,7 @@ pub fn document(
|
|||
|
||||
s.push_str("<h2>seats</h2><div class=\"row\">");
|
||||
for (id, p) in &view.players {
|
||||
player_card(&mut s, *id, p, view);
|
||||
player_card(s, *id, p, view);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
|
|
@ -511,7 +566,16 @@ pub fn document(
|
|||
winners = esc(&winners),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The "your move" section: action cards, numbered fallbacks, the table,
|
||||
/// and pass. Emits nothing when the seat has nothing legal to do.
|
||||
fn move_section(
|
||||
s: &mut String,
|
||||
legal: &[games_ground::GroundCommand],
|
||||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
) {
|
||||
if !legal.is_empty() {
|
||||
s.push_str("<h2>your move</h2><div class=\"row\">");
|
||||
for a in [
|
||||
|
|
@ -529,24 +593,32 @@ pub fn document(
|
|||
// wherever the real target set was narrower, which is almost
|
||||
// everywhere: Investigate is legal on problems 2 and 3 but
|
||||
// not 1 (CB-WP-0017).
|
||||
let targets: Vec<String> = seat
|
||||
// CB-WP-0018 T03: targets and their meanings, in step, both
|
||||
// written by Rust. ADR-0010 D1 forbids the page composing the
|
||||
// second from the first.
|
||||
let offered: Vec<(String, String)> = seat
|
||||
.map(|seat| {
|
||||
legal
|
||||
.iter()
|
||||
.filter_map(|c| crate::input::affordance(c, seat))
|
||||
.filter(|(f, _)| *f == action_id(a))
|
||||
.map(|(_, t)| t)
|
||||
.filter_map(|c| {
|
||||
crate::input::affordance(c, seat)
|
||||
.filter(|(f, _)| *f == action_id(a))
|
||||
.map(|(_, t)| (t, crate::input::describe(c, seat)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let targets: Vec<String> = offered.iter().map(|(t, _)| t.clone()).collect();
|
||||
let descs: Vec<String> = offered.iter().map(|(_, d)| d.clone()).collect();
|
||||
if !targets.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div class=\"card act pick\" data-drop=\"{id}\" \
|
||||
data-targets=\"{targets}\">{a:?}<br>\
|
||||
data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}<br>\
|
||||
<span class=\"k\">onto</span> {names}</div>",
|
||||
id = action_id(a),
|
||||
targets = esc(&targets.join(" ")),
|
||||
descs = esc(&descs.join("|")),
|
||||
names = esc(&target_names(&targets)),
|
||||
);
|
||||
}
|
||||
|
|
@ -572,14 +644,6 @@ pub fn document(
|
|||
"<div class=\"card btn pick\" data-drop=\"pass\">pass \u{2014} decline to act</div>",
|
||||
);
|
||||
}
|
||||
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div id=\"cb-status\">ready</div>\
|
||||
<script>window.CB_ENDPOINT={};</script><script>{SCRIPT}</script></body></html>",
|
||||
json_string(endpoint),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// Quote a string as a JSON literal, so a token can never end the script.
|
||||
|
|
@ -622,6 +686,86 @@ fn json_string(s: &str) -> String {
|
|||
/// element could ever be `seat-0` — the relationship-graph circle took it
|
||||
/// and the seat card the instruction text points at went without. A seat
|
||||
/// is drawn twice and both drawings are the seat.
|
||||
/// One line of the game log: what was done, and what it produced.
|
||||
///
|
||||
/// Built by the caller from `bot::Applied` so this crate stays free of
|
||||
/// the driver, and phrased in the **recorder's** vocabulary via
|
||||
/// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because
|
||||
/// what the player reads should be what the scenario file will say.
|
||||
pub struct LogLine {
|
||||
pub who: String,
|
||||
pub what: String,
|
||||
/// Rendered effects. **Empty is the case that matters:** a command
|
||||
/// that produced no events is the one the player cannot otherwise
|
||||
/// account for.
|
||||
pub effects: Vec<String>,
|
||||
}
|
||||
|
||||
/// The game log, newest last, with the empty case spelled out.
|
||||
fn log_section(s: &mut String, log: &[LogLine]) {
|
||||
s.push_str("<h2>log</h2><div class=\"card\" id=\"cb-log\">");
|
||||
if log.is_empty() {
|
||||
s.push_str("<span class=\"k\">nothing has happened yet</span>");
|
||||
}
|
||||
for line in log {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div><span class=\"k\">{who}</span> {what}",
|
||||
who = esc(&line.who),
|
||||
what = esc(&line.what),
|
||||
);
|
||||
if line.effects.is_empty() {
|
||||
// The silence CB-WP-0018 was reported for, said out loud.
|
||||
s.push_str(" \u{2014} <span class=\"nil\">no effect</span>");
|
||||
} else {
|
||||
for e in &line.effects {
|
||||
let _ = write!(s, "<br><span class=\"eff\">\u{2192} {}</span>", esc(e));
|
||||
}
|
||||
}
|
||||
s.push_str("</div>");
|
||||
}
|
||||
s.push_str("</div>");
|
||||
}
|
||||
|
||||
/// The page a finished game leaves behind (CB-WP-0018 T01).
|
||||
///
|
||||
/// `view` is `None` when the game ended badly: then there is no result to
|
||||
/// draw and saying so is the whole point. Drawing a table for a game that
|
||||
/// crashed would be the same lie the empty page told, dressed up.
|
||||
///
|
||||
/// **This page does not auto-reload.** The old one reloaded on `ok` and
|
||||
/// the reload was refused, which is how a completed game became a blank
|
||||
/// tab.
|
||||
pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String {
|
||||
let mut s = String::with_capacity(4096);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<!doctype html><meta charset=\"utf-8\">\
|
||||
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
||||
<title>GROUND — game over</title><style>{STYLE}</style>\
|
||||
<h1>game over</h1><div class=\"card\">{msg}</div>",
|
||||
msg = esc(message),
|
||||
);
|
||||
match view {
|
||||
Some(v) => body(&mut s, v),
|
||||
None => {
|
||||
s.push_str(
|
||||
"<div class=\"card\">The game ended without a result, so there \
|
||||
is no final table to show. The reason is above.</div>",
|
||||
);
|
||||
}
|
||||
}
|
||||
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 id=\"cb-status\">the game is over</div>\
|
||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
|
||||
endpoint = json_string(endpoint),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn drop_keys(html: &str) -> std::collections::BTreeSet<String> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
let mut rest = html;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,40 @@ pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<
|
|||
}
|
||||
}
|
||||
|
||||
/// What a drop would mean, in a sentence.
|
||||
///
|
||||
/// **ADR-0010 Decision 1 puts this in Rust.** The page may render it; it
|
||||
/// may not compose it. A script that assembled "Attack P2" from an id and
|
||||
/// a seat name would be deriving a game fact, and would be wrong the
|
||||
/// moment a command meant something the ids do not say.
|
||||
pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String {
|
||||
let who = |p: cb_kernel::PlayerId| format!("P{}", p.0 + 1);
|
||||
match command {
|
||||
GroundCommand::SelectAction {
|
||||
action,
|
||||
target,
|
||||
problem,
|
||||
} => {
|
||||
let head = match action {
|
||||
Action::Attack => "Attack",
|
||||
Action::Support => "Support",
|
||||
Action::Solve => "Solve",
|
||||
Action::Investigate => "Investigate",
|
||||
Action::Ground => "Ground",
|
||||
};
|
||||
match (target, problem) {
|
||||
(Some(t), _) => format!("commit {head} against {}", who(*t)),
|
||||
(_, Some(n)) => format!("commit {head} on problem {n}"),
|
||||
_ => format!("commit {head}, untargeted"),
|
||||
}
|
||||
}
|
||||
GroundCommand::SpendFreedom => {
|
||||
format!("{} spends freedom to act again", who(seat))
|
||||
}
|
||||
other => format!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a pointer fact to an index into the legal list.
|
||||
///
|
||||
/// Returns `Err` rather than a default when nothing matches: a drag that
|
||||
|
|
|
|||
|
|
@ -64,13 +64,14 @@ var __all = [];
|
|||
// A DOM node with a real classList and real attributes. CB-WP-0016 found
|
||||
// that a stub too thin to express a failure is how the failure survives;
|
||||
// the previous stub could not express class-toggling at all.
|
||||
function __mk(key, targets, text) {
|
||||
function __mk(key, targets, text, descs) {
|
||||
var n = {
|
||||
parentNode: null,
|
||||
textContent: text || key,
|
||||
style: {},
|
||||
_cls: {},
|
||||
_attr: { 'data-drop': key, 'data-targets': targets || null },
|
||||
_attr: { 'data-drop': key, 'data-targets': targets || null,
|
||||
'data-descs': descs || null },
|
||||
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
|
||||
setAttribute: function (a, v) { this._attr[a] = v; },
|
||||
classList: {
|
||||
|
|
@ -81,7 +82,7 @@ function __mk(key, targets, text) {
|
|||
};
|
||||
return n;
|
||||
}
|
||||
function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; }
|
||||
function __register(key, targets, descs) { var n = __mk(key, targets, key, descs); __all.push(n); return n; }
|
||||
|
||||
var document = {
|
||||
body: __body,
|
||||
|
|
@ -103,7 +104,9 @@ function fetch(url, opts) {
|
|||
// every one of them (CB-EV-0014 section 2).
|
||||
function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); }
|
||||
function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); }
|
||||
function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } }
|
||||
function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9, target: __mk(null, null, "") }); } }
|
||||
function __moveOver(k) { __handlers['pointermove']({ clientX: 5, clientY: 5, target: __find(k) }); }
|
||||
function __ghostText() { return __body.children.length ? __body.children[0].textContent : ""; }
|
||||
function __cancel() { __handlers['pointercancel']({ target: __find(null) }); }
|
||||
function __find(k) {
|
||||
for (var i = 0; i < __all.length; i++) {
|
||||
|
|
@ -230,12 +233,29 @@ pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The `data-descs` of the element carrying `key`, if any.
|
||||
pub fn descriptions(html: &str, key: &str) -> Option<String> {
|
||||
let needle = format!("data-drop=\"{key}\"");
|
||||
let i = html.find(&needle)?;
|
||||
let tail = &html[i..];
|
||||
let end = tail.find('>')?;
|
||||
let k = tail[..end].find("data-descs=\"")?;
|
||||
let t = &tail[k + 12..];
|
||||
t.find('"').map(|e| t[..e].to_string())
|
||||
}
|
||||
|
||||
fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
|
||||
for (key, targets) in droppables(html) {
|
||||
let call = match targets {
|
||||
Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)),
|
||||
None => format!("__register({}, null);", json_lit(&key)),
|
||||
};
|
||||
// Descriptions are registered too: the stub could not express
|
||||
// `data-descs` at all, so the first version of the description
|
||||
// test failed against a DOM that simply did not carry them.
|
||||
let descs = descriptions(html, &key);
|
||||
let call = format!(
|
||||
"__register({}, {}, {});",
|
||||
json_lit(&key),
|
||||
targets.map(|t| json_lit(&t)).unwrap_or("null".into()),
|
||||
descs.map(|d| json_lit(&d)).unwrap_or("null".into()),
|
||||
);
|
||||
ctx.eval(&call)
|
||||
.map_err(|e| format!("register {key}: {e}"))?;
|
||||
}
|
||||
|
|
@ -274,6 +294,15 @@ mod tests {
|
|||
target: Some(PlayerId(1)),
|
||||
problem: None,
|
||||
},
|
||||
// TWO attack targets on purpose. With one, an off-by-one in
|
||||
// the target/description pairing is a no-op and the mutations
|
||||
// that should catch it survive — a fixture too thin to express
|
||||
// the failure, which is how the failure survives (CB-EV-0014).
|
||||
GroundCommand::SelectAction {
|
||||
action: Action::Attack,
|
||||
target: Some(PlayerId(2)),
|
||||
problem: None,
|
||||
},
|
||||
];
|
||||
crate::doc::document(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
|
|
@ -536,6 +565,59 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// **ADR-0010 D1 for descriptions.** Dragging over a legal target
|
||||
/// shows the sentence Rust wrote **for that pair** — not one the page
|
||||
/// assembled, and not a neighbouring pair's.
|
||||
#[test]
|
||||
fn dragging_over_a_target_shows_the_description_rust_wrote_for_it() {
|
||||
let html = page();
|
||||
let ctx = ctx_for(&html);
|
||||
|
||||
// What Rust wrote on the card, read back out of the document.
|
||||
let card = droppables(&html)
|
||||
.into_iter()
|
||||
.find(|(k, _)| k == "action-attack")
|
||||
.expect("the attack card");
|
||||
let targets = card.1.expect("targets");
|
||||
let descs = descriptions(&html, "action-attack").expect("descs");
|
||||
let i = targets
|
||||
.split(' ')
|
||||
.position(|t| t == "seat-1")
|
||||
.expect("seat-1 is a target");
|
||||
let want = descs.split('|').nth(i).expect("a description").to_string();
|
||||
assert!(!want.is_empty(), "the description is blank");
|
||||
|
||||
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);
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
/// Every advertised target has a description; a card offering three
|
||||
/// drops and two sentences would silently mispair them.
|
||||
#[test]
|
||||
fn every_advertised_target_carries_its_own_description() {
|
||||
let html = page();
|
||||
for (key, targets) in droppables(&html) {
|
||||
let Some(targets) = targets else { continue };
|
||||
let descs = descriptions(&html, &key)
|
||||
.unwrap_or_else(|| panic!("{key} advertises targets but no descriptions"));
|
||||
assert_eq!(
|
||||
targets.split(' ').count(),
|
||||
descs.split('|').count(),
|
||||
"{key}: {} target(s) but {} description(s)",
|
||||
targets.split(' ').count(),
|
||||
descs.split('|').count()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_script_blocks_are_extracted_in_order() {
|
||||
let found = scripts(&page());
|
||||
|
|
|
|||
|
|
@ -573,5 +573,59 @@ mod affordances {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gamelog {
|
||||
//! CB-WP-0018 T02.
|
||||
|
||||
use crate::doc::{document_with_log, LogLine};
|
||||
use cb_kernel::PlayerId;
|
||||
|
||||
fn line(effects: &[&str]) -> LogLine {
|
||||
LogLine {
|
||||
who: "P1".into(),
|
||||
what: "select_action action=SOLVE problem=1".into(),
|
||||
effects: effects.iter().map(|s| (*s).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn page(log: &[LogLine]) -> String {
|
||||
document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
"/command?t=x",
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
log,
|
||||
)
|
||||
}
|
||||
|
||||
/// **The case the pass was reported for.** A SOLVE that cannot be
|
||||
/// fulfilled produces no events, and a log built only from events
|
||||
/// would render nothing for it — reproducing the silence the
|
||||
/// maintainer hit when the same move did nothing three rounds
|
||||
/// running.
|
||||
#[test]
|
||||
fn a_command_that_produced_nothing_says_so() {
|
||||
let html = page(&[line(&[])]);
|
||||
assert!(
|
||||
html.contains("no effect"),
|
||||
"a command with no events rendered as if it had done something"
|
||||
);
|
||||
let text = crate::text_of(&html);
|
||||
assert!(text.contains("select_action action=SOLVE problem=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_are_listed_and_an_empty_log_says_it_is_empty() {
|
||||
let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])]));
|
||||
assert!(text.contains("problem 1 claimed by P1"));
|
||||
assert!(
|
||||
!text.contains("no effect"),
|
||||
"a command WITH effects was marked as having none"
|
||||
);
|
||||
assert!(crate::text_of(&page(&[])).contains("nothing has happened yet"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod testfix;
|
||||
|
|
|
|||
182
evidence/CB-EV-0016-the-browser-is-a-client.md
Normal file
182
evidence/CB-EV-0016-the-browser-is-a-client.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# CB-EV-0016 — the browser is a client, and window 1's verdict
|
||||
|
||||
CB-WP-0018 T04. Measured 2026-08-03 at `e8bb726`+. Pass kind `product`,
|
||||
tier **M** (structural M — changes the loop's own constraints; chaos d4=3,
|
||||
no override). **Declaration 1 of chaos window 2**, opened by this pass.
|
||||
|
||||
---
|
||||
|
||||
## 1. A finished game was 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 before touching anything, by driving a real game to completion
|
||||
over HTTP:
|
||||
|
||||
```
|
||||
move 5 accepted → ok
|
||||
GET / → [Errno 111] Connection refused
|
||||
```
|
||||
|
||||
The game had **ended normally** — 5 rounds, 30 commands — and its entire
|
||||
result went to a terminal nobody was reading. `next_choice` only accepts
|
||||
connections *inside* a human decision point, so when `play()` returned the
|
||||
listener died and the page's post-`ok` reload was refused.
|
||||
|
||||
**The browser was a second-class client.** The outcome, the scores, the
|
||||
winners, and every error `run_game` can return were invisible to the only
|
||||
interface a player uses. Now: an 8,998-byte page reading *"GROUND — game
|
||||
over … 30 commands, hash f6c890a65271"*, with the final table and the log.
|
||||
|
||||
It serves until the page posts `done` — the ending carries a *"close — I
|
||||
have read this"* control — with a 600 s linger, because a server that
|
||||
never exits is its own defect and a timeout would race a player reading
|
||||
the result.
|
||||
|
||||
## 2. The control had to be built twice, and the first was worthless
|
||||
|
||||
`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly. It
|
||||
passes. **Deleting the call from `run_game` left it green.**
|
||||
|
||||
That is CB-EV-0012's finding recurring almost verbatim — *"every link was
|
||||
tested and the chain was not"* — and it survived one full round of
|
||||
mutation here before anyone noticed, because the mutation was run and the
|
||||
verdict was read as "no coverage gap" rather than "the test is in the
|
||||
wrong place".
|
||||
|
||||
`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 its failure message prints an **empty page**,
|
||||
which is precisely the symptom that was reported.
|
||||
|
||||
**A weak assertion of mine, caught by itself.** The first version grepped
|
||||
the ending page for `location.reload`. The 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 into
|
||||
existence to satisfy a test rather than a requirement. That is the
|
||||
source-text control shape ADR-0010 D2 demoted three passes ago, reappearing
|
||||
in my own hands.
|
||||
|
||||
## 3. The card report, and what it actually was
|
||||
|
||||
Reported: *"the cards I play by pulling them on a target will not be
|
||||
removed … we will need a discard pile."*
|
||||
|
||||
**A discard pile already existed** — `solution_discard` on `GroundState`,
|
||||
`SolutionDiscarded` removing the card from the hand, and the page already
|
||||
rendering `deck N remaining / discard …`. Building one would have been
|
||||
building a thing that was there, and the only reason that did not happen is
|
||||
that the code was read before the work started.
|
||||
|
||||
What was being dragged are **action** cards. The five GROUND actions are
|
||||
not cards and are correctly never consumed; solution cards leave the hand
|
||||
at **Resolve**, because a selection is a face-down commit.
|
||||
|
||||
But the report pointed at something real. Measured live:
|
||||
|
||||
| move | hand | discard |
|
||||
|---|---|---|
|
||||
| 1 · Investigate → problem-2 | 2 cards | none |
|
||||
| 2 · Investigate → problem-3 | **3 cards** | none |
|
||||
| 3–5 · Solve → problem-1 | **4 cards, unchanged** | 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, and `legal_commands` offers Solve on every face-up
|
||||
problem without consulting the hand.
|
||||
|
||||
**Raised for `ground-game`, not decided here** (INTENT defers game
|
||||
semantics): *should SOLVE be selectable against a face-down problem, or
|
||||
against a suit the seat cannot match?* A face-down commit you cannot
|
||||
fulfil is a plausible bluff in a commit/reveal game with DARVO, which is
|
||||
exactly why it is not this repo's call.
|
||||
|
||||
## 4. The log, and the limit of what it can honestly say
|
||||
|
||||
`bot::Journal` — `Applied { actor, command, events }` appended by the
|
||||
driver through the new `play_journaled`; `play` delegates with `None`, so
|
||||
nothing existing changed. `BotGame.events` is the same information but only
|
||||
after `play` returns, which is no use to a page rendered mid-game.
|
||||
|
||||
Phrased with `record::to_step`, so *what the player reads is what the
|
||||
scenario file will say*, and all 29 `GroundEvent` variants render in words
|
||||
rather than `{:?}`.
|
||||
|
||||
**A command that produced no events says `no effect`**, and the mutation
|
||||
removing that branch goes red.
|
||||
|
||||
**The honest limit:** the reported SOLVE case does *not* render as `no
|
||||
effect`, because the SOLVE resolves 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 — a large improvement on silence, but
|
||||
still an inference. Making it explicit would require the renderer to decide
|
||||
*why* a rule did nothing, which is a second implementation of the rules and
|
||||
is what this task's own control forbids. Left as an inference deliberately.
|
||||
|
||||
## 5. The explanation, and two mutations that a thin fixture defeated
|
||||
|
||||
Every advertised target now carries a sentence Rust wrote for **that pair**
|
||||
(`data-descs`, in step with `data-targets`), shown at the pointer while
|
||||
dragging over it. ADR-0010 D1 binds: the page renders it, never composes
|
||||
it.
|
||||
|
||||
Both mutations — showing a neighbouring pair's text, and letting targets
|
||||
and descriptions fall out of step — **initially survived**, because the
|
||||
test 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 again, one level in: *a stub too thin to
|
||||
express a failure is how the failure survives*. The fixture now offers two
|
||||
attack targets on purpose, and both mutations go red.
|
||||
|
||||
## 6. Window 1's verdict, and a rate change on n=2
|
||||
|
||||
Twelve declarations, two overrides, one each way, **both changed the
|
||||
outcome** — so window 1's retirement condition was not met and the
|
||||
mechanism is kept. The full table is in `InnerLoopReference.md` §Chaos
|
||||
roll.
|
||||
|
||||
**Rate dropped d4 → d8; window 2 opened at 12 declarations; new retirement
|
||||
condition: retire if an override changes nothing twice running.**
|
||||
|
||||
**The weakest part of this pass, stated plainly: it is a rate change argued
|
||||
from n=2.** The alternative — keep d4 for a second window and decide with
|
||||
four points — was live, and was rejected only because a quarter of all
|
||||
declarations is a large standing tax to pay for evidence. So window 2
|
||||
carries a falsifier: **if it produces no override at all, that is evidence
|
||||
the rate went too far**, not that the mechanism is healthy. A window that
|
||||
cannot fire cannot be evaluated, which is the exact failure d10 had.
|
||||
|
||||
## 7. Cost
|
||||
|
||||
| pass | kind | responses | cost | $/response |
|
||||
|---|---|---|---|---|
|
||||
| CB-WP-0016 | product | 64 | $14.93 | 0.233 |
|
||||
| **CB-WP-0017** | product | 40 | **$9.48** | 0.237 |
|
||||
| CB-WP-0018 | product | *provisional — not quoted* | | |
|
||||
|
||||
Read by **re-running `make status` at the moment of writing**, which is
|
||||
CB-EV-0015 §6's correction applied for the first time: quoting a figure
|
||||
remembered from earlier in a session defeats the rule even when the
|
||||
boundary is right. CB-WP-0017 was reported at $5.19/23 mid-flight and
|
||||
settled at **$9.48/40** — 83% higher. **Six for six, always low.**
|
||||
|
||||
**Meta budget 0% `[ok]`**, all three trailing passes product.
|
||||
|
||||
## 8. Open
|
||||
|
||||
- **INTENT stage 1: the human check.** Three runs, three defects no test
|
||||
could reach. Everything in this pass is verified by tests, mutation and a
|
||||
live socket; nothing perceptual is.
|
||||
- **For `ground-game`:** should SOLVE be selectable against a face-down
|
||||
problem or an unmatchable suit? §3.
|
||||
- **The self-quoting rule** now has both halves recorded but is still not
|
||||
written into the loop spec.
|
||||
- **AM-4b's scope defect (408,237 uncounted lines)** and its proc-macro
|
||||
share.
|
||||
- **`python3` as a toolchain dependency was never argued.**
|
||||
- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000.
|
||||
- **ADR-0007 D3's acquisition rule** and **D5** remain unratified;
|
||||
ADR-0010 rests on the latter.
|
||||
|
|
@ -409,10 +409,23 @@ const MAX_ATTEMPTS: usize = 8;
|
|||
/// Seats are matched to `policies` by index: `PlayerId(n)` gets
|
||||
/// `policies[n]`.
|
||||
pub fn play<'a>(
|
||||
mut state: GroundState,
|
||||
state: GroundState,
|
||||
policies: &mut [Box<dyn Policy + 'a>],
|
||||
) -> Result<BotGame, BotError> {
|
||||
let mut log = Log::default();
|
||||
play_journaled(state, policies, None)
|
||||
}
|
||||
|
||||
/// The same, appending every applied command and its events to `journal`
|
||||
/// as it goes, for a caller rendering the game while it runs.
|
||||
pub fn play_journaled<'a>(
|
||||
mut state: GroundState,
|
||||
policies: &mut [Box<dyn Policy + 'a>],
|
||||
journal: Option<Journal>,
|
||||
) -> Result<BotGame, BotError> {
|
||||
let mut log = Log {
|
||||
journal,
|
||||
..Log::default()
|
||||
};
|
||||
|
||||
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
|
||||
for seat in &seats {
|
||||
|
|
@ -504,11 +517,33 @@ pub fn play<'a>(
|
|||
})
|
||||
}
|
||||
|
||||
/// One command and everything it produced.
|
||||
///
|
||||
/// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's
|
||||
/// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a
|
||||
/// player can select it three rounds running and change nothing. A journal
|
||||
/// built only from events would show nothing for those and reproduce the
|
||||
/// silence; keeping the command with an empty `events` is what lets a
|
||||
/// reader say *"this happened and did nothing"*.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Applied {
|
||||
pub actor: Actor,
|
||||
pub command: GroundCommand,
|
||||
pub events: Vec<crate::GroundEvent>,
|
||||
}
|
||||
|
||||
/// A live account of a game in progress, shared with whoever is watching.
|
||||
///
|
||||
/// `BotGame.events` is the same information but only after `play` returns,
|
||||
/// which is no use to a page rendered mid-game.
|
||||
pub type Journal = std::rc::Rc<std::cell::RefCell<Vec<Applied>>>;
|
||||
|
||||
/// What the driver accumulates while a game runs.
|
||||
#[derive(Default)]
|
||||
struct Log {
|
||||
events: Vec<crate::GroundEvent>,
|
||||
steps: Vec<(Actor, GroundCommand)>,
|
||||
journal: Option<Journal>,
|
||||
}
|
||||
|
||||
/// Offer one seat its legal commands and apply what the policy picks.
|
||||
|
|
@ -573,6 +608,13 @@ fn apply(
|
|||
for event in &produced {
|
||||
state.fold(event);
|
||||
}
|
||||
if let Some(j) = &log.journal {
|
||||
j.borrow_mut().push(Applied {
|
||||
actor,
|
||||
command: cmd.clone(),
|
||||
events: produced.clone(),
|
||||
});
|
||||
}
|
||||
log.events.extend(produced);
|
||||
log.steps.push((actor, cmd.clone()));
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -120,15 +120,15 @@ retire_if = "two passes run with no finding while artifacts keep growing — tha
|
|||
id = "CHAOS"
|
||||
name = "the chaos roll"
|
||||
target = ""
|
||||
checks = "d4 on each tier declaration, 12-declaration calibration window"
|
||||
checks = "d8 on each tier declaration, 12-declaration calibration window (window 2, opened 2026-08-03; window 1 ran at d4)"
|
||||
added = "2026-07-30"
|
||||
review_by = "2026-09-30"
|
||||
review_by = "2026-11-30"
|
||||
caught = [
|
||||
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
|
||||
"CB-WP-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it",
|
||||
"CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)",
|
||||
]
|
||||
retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible"
|
||||
retire_if = "an override changes nothing twice running (window 2 condition, CB-WP-0018 T04). Window 1's condition — no override changing the outcome — was NOT met: both did, so the mechanism was kept and the rate dropped d4 → d8 instead"
|
||||
# VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides).
|
||||
# Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a
|
||||
# defect in the existing renderer that the deleted survey would have walked
|
||||
|
|
|
|||
|
|
@ -120,17 +120,22 @@ are never skipped for code-producing work.
|
|||
| **M** | Survey and ADR merged into one document; review optional | Touches a canonical interface, adds/updates an external dependency, **or changes whether or how the loop constrains its own operation** — budgets, gates, review requirements, or these tier rules (v1.6, ADR-0006 D5) |
|
||||
| **S** | One provenance paragraph in the commit message | Everything else (utilities, fixes, refactors inside a boundary) |
|
||||
|
||||
**The chaos roll.** After deriving the structural tier, roll **d4**
|
||||
(`shuf -i 1-4 -n 1`). On a **4**, the tier is instead picked uniformly at
|
||||
**The chaos roll.** After deriving the structural tier, roll **d8**
|
||||
(`shuf -i 1-8 -n 1`). On an **8**, the tier is instead picked uniformly at
|
||||
random (`shuf -e S M L -n 1`), overriding the structural derivation — up or
|
||||
down.
|
||||
|
||||
> **Calibration window, opened 2026-07-31, running to 12 tier
|
||||
> declarations** (declaration 4 of 12 as of 2026-08-01). The rate was
|
||||
> raised from d10 to d4 because at d10 the mechanism never fired and
|
||||
> so prevented its own evaluation. Record the roll every time,
|
||||
> including when it changes nothing (`tier: L (structural L, chaos 4)`).
|
||||
> Rationale, cost estimate and the two dead rolls:
|
||||
> **Window 1 closed 2026-08-02** at 12 declarations, 2 overrides, one each
|
||||
> way, and **both changed the outcome** — so the mechanism was kept and
|
||||
> the rate dropped d4 → d8 (CB-EV-0015 §5, CB-EV-0016 §4).
|
||||
>
|
||||
> **Window 2, opened 2026-08-03 at d8**, running to 12 declarations.
|
||||
> Retirement condition: **retire if an override changes nothing twice
|
||||
> running.**
|
||||
>
|
||||
> Record the roll every time, including when it changes nothing
|
||||
> (`tier: L (structural L, chaos 8)`). Why the rate fell, why n=2 makes
|
||||
> that the weakest part of the decision, and the dead rolls:
|
||||
> `specs/InnerLoopReference.md` §Chaos roll — calibration.
|
||||
|
||||
Chaos limits: a rolled-down tier relaxes *process* weight only. Invariants
|
||||
|
|
|
|||
|
|
@ -195,3 +195,35 @@ four implementation rules the pass earned, and the requirement that
|
|||
evidence state what it does not support. Rationale and the failures behind
|
||||
each: `history/260731-inner-loop-retrospective.md`.
|
||||
|
||||
|
||||
## Chaos roll — window 1's verdict and the d4 → d8 change
|
||||
|
||||
*(CB-WP-0018 T04, 2026-08-03. Full argument in `evidence/CB-EV-0015.md` §5
|
||||
and `evidence/CB-EV-0016.md` §4.)*
|
||||
|
||||
Window 1 ran 2026-07-31 → 2026-08-02, twelve declarations, at d4 after an
|
||||
earlier d10 that never fired and so prevented its own evaluation.
|
||||
|
||||
**Two overrides, one in each direction, and both changed the outcome**, so
|
||||
window 1's retirement condition — *"the window closes with no overridden
|
||||
tier producing a different outcome than the argued one"* — was not met:
|
||||
|
||||
| pass | roll | what the override bought |
|
||||
|---|---|---|
|
||||
| CB-WP-0011 | structural L → **S** | the deleted survey would have opened on 2D toolkits; the pass instead found the existing text renderer showing 24 of 41 view fields. Priced on the same subject: 0.099 $/response at S against 0.123 at L |
|
||||
| CB-WP-0017 | structural S → **M** | ADR-0010. At tier S the page's script would have grown from *"it does one thing"* to holding a drag, following the pointer and marking other elements under a one-paragraph commit note, silently outgrowing ADR-0007 D5 |
|
||||
|
||||
**Why the rate fell.** Both were informative *because they were rare*. At
|
||||
d4 the mechanism overrides a quarter of all declarations, at which point it
|
||||
stops being a calibration on the tier table and becomes a second tier
|
||||
table. d8 keeps the mechanism and restores its rarity.
|
||||
|
||||
**The weakest part of this decision, stated plainly:** it is a rate change
|
||||
argued from **n=2**. The alternative — keep d4 for a second window and
|
||||
decide with four data points — was live and was rejected only because a
|
||||
quarter of declarations is a large standing tax to pay for evidence.
|
||||
|
||||
So window 2 carries a falsifier: **if it produces no override at all, that
|
||||
is evidence the rate went too far**, not evidence the mechanism is
|
||||
healthy. A window that cannot fire cannot be evaluated, which is the exact
|
||||
failure d10 had.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use std::rc::Rc;
|
|||
|
||||
use cb_game_runtime::{Project, Viewer};
|
||||
use cb_kernel::PlayerId;
|
||||
use cb_render_html::{document, resolve, Guard, PointerFact, Request};
|
||||
use cb_render_html::{resolve, Guard, PointerFact, Request};
|
||||
use games_ground::bot::{Choice, Policy};
|
||||
use games_ground::{GroundCommand, GroundState};
|
||||
|
||||
|
|
@ -28,6 +28,9 @@ pub struct Server {
|
|||
listener: TcpListener,
|
||||
guard: Guard,
|
||||
log: RefCell<Vec<String>>,
|
||||
/// The live account of the game, shared with the driver (CB-WP-0018
|
||||
/// T02). Read at render time so the page shows what has happened.
|
||||
journal: games_ground::bot::Journal,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
|
|
@ -41,6 +44,7 @@ impl Server {
|
|||
listener,
|
||||
guard: Guard::mint(port),
|
||||
log: RefCell::new(Vec::new()),
|
||||
journal: games_ground::bot::Journal::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +54,47 @@ impl Server {
|
|||
self.guard.page_url()
|
||||
}
|
||||
|
||||
/// The journal the driver appends to; hand it to `play_journaled`.
|
||||
pub fn journal(&self) -> games_ground::bot::Journal {
|
||||
self.journal.clone()
|
||||
}
|
||||
|
||||
/// The log as the page renders it, in the recorder's vocabulary.
|
||||
///
|
||||
/// `record::to_step` is reused rather than phrased afresh: what the
|
||||
/// player reads is what the scenario file will say. The effects are
|
||||
/// the events the command actually produced, and a command that
|
||||
/// produced none says so — that is the case CB-WP-0018 was reported
|
||||
/// for.
|
||||
fn log_lines(&self) -> Vec<cb_render_html::doc::LogLine> {
|
||||
self.journal
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let step = games_ground::record::to_step(a.actor, &a.command);
|
||||
let mut what = step.cmd.clone();
|
||||
for (k, v) in &step.args {
|
||||
let rendered = match v {
|
||||
serde_yaml::Value::String(s) => s.clone(),
|
||||
other => serde_yaml::to_string(other)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string(),
|
||||
};
|
||||
what.push_str(&format!(" {k}={rendered}"));
|
||||
}
|
||||
cb_render_html::doc::LogLine {
|
||||
who: match a.actor {
|
||||
cb_kernel::Actor::Player(p) => format!("P{}", p.0 + 1),
|
||||
cb_kernel::Actor::System => "the round".to_string(),
|
||||
},
|
||||
what,
|
||||
effects: a.events.iter().map(event_line).collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What was refused, for the evidence a session leaves behind.
|
||||
pub fn refusals(&self) -> Vec<String> {
|
||||
self.log.borrow().clone()
|
||||
|
|
@ -93,7 +138,14 @@ impl Server {
|
|||
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/") => {
|
||||
let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass);
|
||||
let page = cb_render_html::doc::document_with_log(
|
||||
&view,
|
||||
legal,
|
||||
&self.guard.endpoint(),
|
||||
Some(seat),
|
||||
may_pass,
|
||||
&self.log_lines(),
|
||||
);
|
||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||
}
|
||||
("POST", "/command") => {
|
||||
|
|
@ -122,6 +174,82 @@ impl Server {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the end of the game until the player has seen it.
|
||||
///
|
||||
/// **The defect this exists for (CB-WP-0018 T01):** `next_choice`
|
||||
/// only accepts connections *inside* a decision point, so when
|
||||
/// `play()` returned the listener died and the page's post-`ok`
|
||||
/// reload was refused. Measured: a game ended normally at 5 rounds
|
||||
/// and 30 commands, its whole result went to the terminal, and the
|
||||
/// browser got `Connection refused`. **A crash and a win rendered
|
||||
/// identically — as nothing.**
|
||||
///
|
||||
/// `final_view` is `None` when the game ended badly; then `message`
|
||||
/// is the reason and the page says the game ended without a result
|
||||
/// rather than drawing a table that never happened.
|
||||
///
|
||||
/// **How it ends, and why that needed deciding:** a server that never
|
||||
/// exits is its own defect, and a timeout would race a player reading
|
||||
/// the result. It serves until the page tells it the result has been
|
||||
/// seen — the terminal page carries a `done` control and posts it —
|
||||
/// with `linger` as a bound so an abandoned tab cannot hold the
|
||||
/// process open forever.
|
||||
pub fn serve_end(
|
||||
&self,
|
||||
final_view: Option<&games_ground::view::GroundView>,
|
||||
message: &str,
|
||||
linger: std::time::Duration,
|
||||
) -> Result<(), 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(());
|
||||
}
|
||||
let (mut stream, _) = match self.listener.accept() {
|
||||
Ok(pair) => pair,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
continue;
|
||||
}
|
||||
Err(e) => break Err(format!("accept: {e}")),
|
||||
};
|
||||
stream.set_nonblocking(false).ok();
|
||||
let Ok(raw) = read_request(&mut stream) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(req) = Request::parse(&raw) else {
|
||||
respond(&mut stream, 400, "text/plain", "bad request");
|
||||
continue;
|
||||
};
|
||||
if let Err(refusal) = self.guard.admit(&req) {
|
||||
self.log.borrow_mut().push(refusal.to_string());
|
||||
respond(&mut stream, 403, "text/plain", &refusal.to_string());
|
||||
continue;
|
||||
}
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/") => {
|
||||
let page = cb_render_html::doc::ending(
|
||||
final_view,
|
||||
message,
|
||||
&self.guard.endpoint(),
|
||||
&self.log_lines(),
|
||||
);
|
||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||
}
|
||||
("POST", "/command") => {
|
||||
respond(&mut stream, 200, "text/plain", "closed");
|
||||
break Ok(());
|
||||
}
|
||||
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
|
||||
}
|
||||
};
|
||||
self.listener.set_nonblocking(false).ok();
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// One seat's view of the shared server.
|
||||
|
|
@ -161,6 +289,67 @@ impl Policy for SeatPolicy {
|
|||
}
|
||||
}
|
||||
|
||||
/// One event, in words a player can read.
|
||||
///
|
||||
/// Deliberately terse and derived from the event itself, never from the
|
||||
/// state afterwards: a log that re-narrates state is a second
|
||||
/// implementation of the rules and will drift from the first.
|
||||
fn event_line(e: &games_ground::GroundEvent) -> String {
|
||||
use games_ground::GroundEvent as E;
|
||||
let p = |x: &cb_kernel::PlayerId| format!("P{}", x.0 + 1);
|
||||
match e {
|
||||
E::ActionSelected { player, selection } => match (selection.target, selection.problem) {
|
||||
(Some(t), _) => format!("{} chose {:?} on {}", p(player), selection.action, p(&t)),
|
||||
(_, Some(n)) => format!("{} chose {:?} on problem {n}", p(player), selection.action),
|
||||
_ => format!("{} chose {:?}", p(player), selection.action),
|
||||
},
|
||||
E::Revealed => "selections revealed".into(),
|
||||
E::StressSet { player, stress } => format!("{} stress now {stress}", p(player)),
|
||||
E::FreedomSpent { player } => format!("{} spent freedom", p(player)),
|
||||
E::FreedomReadied { player } => format!("{} freedom ready", p(player)),
|
||||
E::RelationFormed { pair, relation } => {
|
||||
format!("{} and {} \u{2014} {relation:?}", p(&pair.0), p(&pair.1))
|
||||
}
|
||||
E::RelationBroken { pair } => format!("{} and {} no longer tied", p(&pair.0), p(&pair.1)),
|
||||
E::AttackCancelled { attacker, target } => {
|
||||
format!("{}'s attack on {} cancelled", p(attacker), p(target))
|
||||
}
|
||||
E::ProblemRevealed { problem } => format!("problem {problem} turned face up"),
|
||||
E::SolutionDrawn { player, .. } => format!("{} drew a solution", p(player)),
|
||||
E::SolutionDiscarded { player, card } => {
|
||||
format!("{} spent a {:?} solution", p(player), card.suit)
|
||||
}
|
||||
E::ProblemClaimed { problem, by } => format!("problem {problem} claimed by {}", p(by)),
|
||||
E::ProblemDenied { problem } => format!("problem {problem} denied"),
|
||||
E::ProblemProtected { problem } => format!("problem {problem} protected"),
|
||||
E::ProblemRestored { problem } => format!("problem {problem} restored"),
|
||||
E::ProtectionGained { player } => format!("{} gained protection", p(player)),
|
||||
E::BlameRemoved { player, owner } => {
|
||||
format!("{} cleared {}'s blame", p(player), p(owner))
|
||||
}
|
||||
E::FocusPlaced { owner, target } => format!("{} focused on {}", p(owner), p(target)),
|
||||
E::FocusFlippedToBlame { owner, target } => {
|
||||
format!("{}'s focus on {} became blame", p(owner), p(target))
|
||||
}
|
||||
E::DarvoTriggered { player } => format!("{} entered DARVO", p(player)),
|
||||
E::DarvoAdvanced { player, stage } => format!("{} DARVO \u{2192} {stage:?}", p(player)),
|
||||
E::DarvoEnded { player } => format!("{} left DARVO", p(player)),
|
||||
E::DarvoTargetChosen { player, .. } => format!("{} named a DARVO target", p(player)),
|
||||
E::GroundModeChosen { player, mode, .. } => {
|
||||
format!("{} grounded as {mode:?}", p(player))
|
||||
}
|
||||
E::SupportAnswered { player, response } => {
|
||||
format!("{} answered support with {response:?}", p(player))
|
||||
}
|
||||
E::DeckReshuffled { .. } => "the discard was reshuffled into the deck".into(),
|
||||
E::RoundEnded { round, next_lead } => {
|
||||
format!("round {round} ended; {} leads next", p(next_lead))
|
||||
}
|
||||
E::StepAdvanced { step } => format!("step \u{2192} {step:?}"),
|
||||
E::GameEnded { .. } => "the game ended".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
|
|
@ -225,6 +414,226 @@ mod tests {
|
|||
/// sent. Two concurrent clients would race the server's `accept`, and
|
||||
/// a test whose outcome depends on which socket wins is worse than no
|
||||
/// test.
|
||||
/// **The defect CB-WP-0018 T01 exists for.** After the game ends the
|
||||
/// browser must get the result, not a refused connection.
|
||||
///
|
||||
/// Measured before the fix, driving a real game to completion over
|
||||
/// HTTP: move 5 accepted, then `GET /` → `[Errno 111] Connection
|
||||
/// refused`, while the whole outcome went to a terminal nobody was
|
||||
/// reading. A crash and a win rendered identically — as nothing.
|
||||
#[test]
|
||||
fn the_end_of_the_game_reaches_the_browser() {
|
||||
let server = Server::bind(0).expect("bind");
|
||||
let port = server.listener.local_addr().unwrap().port();
|
||||
let token = server.guard.token().to_string();
|
||||
let client = converse(
|
||||
port,
|
||||
vec![
|
||||
format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
||||
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: 17\r\n\r\n\
|
||||
down=done&up=done"
|
||||
),
|
||||
],
|
||||
);
|
||||
let view = state().project(Viewer::Spectator);
|
||||
server
|
||||
.serve_end(
|
||||
Some(&view),
|
||||
"30 commands, hash f6c890a65271",
|
||||
std::time::Duration::from_secs(20),
|
||||
)
|
||||
.expect("serve_end");
|
||||
let replies = client.join().expect("client");
|
||||
|
||||
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
|
||||
assert!(replies[0].contains("game over"), "the page did not say so");
|
||||
assert!(
|
||||
replies[0].contains("30 commands"),
|
||||
"the result did not reach the page"
|
||||
);
|
||||
// It must not auto-reload into a refused connection, which is how
|
||||
// a completed game became a blank tab in the first place.
|
||||
//
|
||||
// Asserted on BEHAVIOUR, not on source text. The first draft
|
||||
// grepped the page for "location.reload" and failed: the ending
|
||||
// page reuses `SCRIPT`, whose reload is guarded by
|
||||
// `t.indexOf('ok') === 0`. Grepping for the string would have
|
||||
// forced a second script to satisfy a test rather than a
|
||||
// requirement — the exact weak-control shape ADR-0010 D2 demoted.
|
||||
// What matters is that the endpoint cannot answer "ok".
|
||||
assert!(
|
||||
!replies[1].contains("ok"),
|
||||
"the ending endpoint answered something the page reloads on: {}",
|
||||
replies[1]
|
||||
);
|
||||
assert!(replies[1].contains("closed"), "{}", replies[1]);
|
||||
}
|
||||
|
||||
/// A game that ended badly must say so rather than draw a table for a
|
||||
/// game that never happened.
|
||||
#[test]
|
||||
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
|
||||
let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[]);
|
||||
assert!(
|
||||
page.contains("P1 ran out of input"),
|
||||
"the reason is missing"
|
||||
);
|
||||
assert!(page.contains("without a result"));
|
||||
assert!(
|
||||
!page.contains("relationships"),
|
||||
"a failed game drew a table anyway"
|
||||
);
|
||||
}
|
||||
|
||||
/// A writer the test can read while the game is still running.
|
||||
#[derive(Clone)]
|
||||
struct SharedOut(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
impl Write for SharedOut {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().expect("out").extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
let mut out = String::new();
|
||||
let _ = s.read_to_string(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// **The chain, not the links.** `the_end_of_the_game_reaches_the_browser`
|
||||
/// calls `serve_end` directly, so it passes even when `run_game` never
|
||||
/// calls it — proven: deleting that call left it green. That is the
|
||||
/// defect class this project keeps meeting (CB-EV-0012: *"every link
|
||||
/// was tested and the chain was not"*), and it survived one round of
|
||||
/// it here before this test existed.
|
||||
///
|
||||
/// So: run the real `play()` with a browser seat, drive a real game to
|
||||
/// its end over a real socket, and require the last page to be the
|
||||
/// ending rather than a refused connection.
|
||||
#[test]
|
||||
fn a_real_game_played_to_its_end_leaves_the_ending_on_screen() {
|
||||
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: 7,
|
||||
players: 3,
|
||||
human_seats: vec![0],
|
||||
bot: "random".into(),
|
||||
replay_dir: None,
|
||||
record: None,
|
||||
serve: Some(0),
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
)
|
||||
});
|
||||
|
||||
// The URL is printed as soon as the listener binds.
|
||||
let url = 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());
|
||||
break rest[..end].to_string();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
};
|
||||
let port: u16 = url["http://127.0.0.1:".len()..]
|
||||
.split('/')
|
||||
.next()
|
||||
.expect("port")
|
||||
.parse()
|
||||
.expect("port number");
|
||||
let token = url.split("t=").nth(1).expect("token").to_string();
|
||||
|
||||
// Play until the page stops offering moves — which is the ending.
|
||||
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);
|
||||
// A spatial move if one is offered; otherwise the numbered
|
||||
// fallback or pass, which is what the page offers at steps
|
||||
// with no draggable action. Breaking here instead would end
|
||||
// the walk mid-game and assert nothing.
|
||||
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("a target")
|
||||
.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 {
|
||||
break;
|
||||
};
|
||||
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()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Before CB-WP-0018 this GET was a refused connection and the
|
||||
// whole result went to a terminal nobody was reading.
|
||||
assert!(
|
||||
last.contains("game over"),
|
||||
"the last page the player saw was not the ending: {}",
|
||||
&last[..last.len().min(300)]
|
||||
);
|
||||
assert!(last.contains("commands, hash"), "no result on the page");
|
||||
// CB-WP-0018 T02: the log is on the page, and it came from the
|
||||
// journal the driver filled -- not from re-reading the state.
|
||||
assert!(last.contains("<h2>log</h2>"), "no log section");
|
||||
assert!(
|
||||
!last.contains("nothing has happened yet"),
|
||||
"a finished game reported an empty log"
|
||||
);
|
||||
assert!(
|
||||
last.contains("select_action"),
|
||||
"the log is not in the recorder's vocabulary"
|
||||
);
|
||||
|
||||
// Let the game thread finish: tell it the result has been seen.
|
||||
let body = "down=done&up=done";
|
||||
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()
|
||||
),
|
||||
);
|
||||
game.join().expect("game thread").expect("the game ran");
|
||||
}
|
||||
|
||||
fn converse(port: u16, requests: Vec<String>) -> std::thread::JoinHandle<Vec<String>> {
|
||||
std::thread::spawn(move || {
|
||||
requests
|
||||
|
|
|
|||
|
|
@ -181,6 +181,29 @@ impl<R: BufRead, W: Write> Policy for HumanPolicy<'_, R, W> {
|
|||
}
|
||||
}
|
||||
|
||||
/// How long the terminal page stays available for an abandoned tab.
|
||||
///
|
||||
/// A server that never exits is its own defect; a short timeout races a
|
||||
/// player reading the result. So it serves until the page posts `done`,
|
||||
/// with this only as the bound.
|
||||
const END_LINGER: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// Show the browser that the game ended badly, then return the error.
|
||||
///
|
||||
/// CB-WP-0018 T01: this path used to `return Err(...)` straight to a
|
||||
/// terminal nobody was reading, and the browser got a refused connection —
|
||||
/// identical to a normal win. An error the only interface cannot see is
|
||||
/// not reported.
|
||||
fn end_badly<T>(
|
||||
server: &Option<std::rc::Rc<crate::hotseat::Server>>,
|
||||
msg: String,
|
||||
) -> Result<T, String> {
|
||||
if let Some(s) = server {
|
||||
let _ = s.serve_end(None, &msg, END_LINGER);
|
||||
}
|
||||
Err(msg)
|
||||
}
|
||||
|
||||
fn bot_policy<'a>(kind: &str, seed: u64) -> Result<Box<dyn Policy + 'a>, String> {
|
||||
match kind {
|
||||
"greedy" => Ok(Box::new(GreedyPolicy)),
|
||||
|
|
@ -244,20 +267,29 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
}
|
||||
}
|
||||
|
||||
let result = games_ground::bot::play(initial, &mut policies);
|
||||
// CB-WP-0018 T02: the browser seat's page renders the journal as it
|
||||
// fills, so a player sees what each command produced -- including the
|
||||
// commands that produced nothing.
|
||||
let result = match &server {
|
||||
Some(srv) => games_ground::bot::play_journaled(initial, &mut policies, Some(srv.journal())),
|
||||
None => games_ground::bot::play(initial, &mut policies),
|
||||
};
|
||||
|
||||
// A human seat that ran out of input reports *that*, not the
|
||||
// out-of-range index it had to return to get here.
|
||||
let human_failure = failure.borrow().clone();
|
||||
let game = match (result, human_failure) {
|
||||
(_, Some(msg)) => return Err(msg),
|
||||
(_, Some(msg)) => return end_badly(&server, msg),
|
||||
(Err(BotError::IllegalChoice { seat, offered, .. }), None) => {
|
||||
return Err(format!(
|
||||
"{} chose a command outside the {offered} offered",
|
||||
seat_name(seat)
|
||||
))
|
||||
return end_badly(
|
||||
&server,
|
||||
format!(
|
||||
"{} chose a command outside the {offered} offered",
|
||||
seat_name(seat)
|
||||
),
|
||||
)
|
||||
}
|
||||
(Err(e), None) => return Err(e.to_string()),
|
||||
(Err(e), None) => return end_badly(&server, e.to_string()),
|
||||
(Ok(game), None) => game,
|
||||
};
|
||||
|
||||
|
|
@ -287,6 +319,15 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
let _ = w.flush();
|
||||
drop(w);
|
||||
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
let scenario = games_ground::record::to_scenario(
|
||||
"ground/cb-play-session",
|
||||
config.seed,
|
||||
|
|
|
|||
325
workplans/CB-WP-0018-the-browser-is-a-client.md
Normal file
325
workplans/CB-WP-0018-the-browser-is-a-client.md
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
---
|
||||
id: CB-WP-0018
|
||||
kind: product
|
||||
title: "The browser is a client: game over, a log, and where a drop goes"
|
||||
status: done
|
||||
---
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue