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 }"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue