Notes were written to a file and shown back nowhere — the shape trials.py's own docstring calls this project's signature failure in a new medium. The log now carries the player's comments where they were made. Position is the feature: a remark like "why did that do nothing?" is about the move above it, and collected at the bottom it is a sentence with no subject. round/step cannot order a note against the log because several commands share a step, so the server records how many commands had been played — which it knows exactly — and the page places it there. A comment must not be readable as something the game did. The log is the recorder's vocabulary; notes render in their own block, attributed to the player, quoted. The .note CSS already existed and nothing had ever used it. No column was added to the trial log. trials.py skipped any row that was not five cells, silently, so a sixth column would have made `make trials` report zero notes for every log at once. That latent defect is fixed on its own terms: a wrong column count now raises, and the walk reports the real reason rather than blaming a missing block for every failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
499d9fe3d7
commit
4ddff3b17c
8 changed files with 455 additions and 26 deletions
|
|
@ -930,7 +930,7 @@ pub fn document(
|
|||
},
|
||||
seat,
|
||||
may_pass,
|
||||
&[],
|
||||
Account::of(&[]),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
|
@ -955,7 +955,7 @@ pub fn document_with_log(
|
|||
to: Endpoints<'_>,
|
||||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
log: &[LogLine],
|
||||
log: Account<'_>,
|
||||
meta: &[String],
|
||||
) -> String {
|
||||
let (endpoint, note_to) = (to.command, to.note);
|
||||
|
|
@ -1305,13 +1305,81 @@ pub struct LogLine {
|
|||
pub effects: Vec<String>,
|
||||
}
|
||||
|
||||
/// One thing the player said, placed in the account (CB-WP-0032).
|
||||
///
|
||||
/// **`after` is a count of commands, not a round.** A note anchored to
|
||||
/// `round`/`step` cannot be ordered against the log, because several
|
||||
/// commands share a step and the log is a sequence of commands — placing
|
||||
/// by round would put a note *somewhere in* the right round, which for a
|
||||
/// remark about a specific move is the wrong position stated confidently.
|
||||
/// The server knows exactly how many commands had been played when the
|
||||
/// note was written, so that is what it records.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogNote {
|
||||
/// Commands played when this was written; `0` is before any move.
|
||||
pub after: usize,
|
||||
pub round: u8,
|
||||
/// Where in the round, or `after the end` for a post-game note.
|
||||
pub step: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The account of the session: what happened, and what the player said
|
||||
/// about it, in one sequence.
|
||||
///
|
||||
/// **A struct rather than two parameters** because both pages take both
|
||||
/// and `document_with_log` was already at seven arguments — and because
|
||||
/// the two are only meaningful together, which is the point of showing
|
||||
/// them interleaved.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Account<'a> {
|
||||
pub lines: &'a [LogLine],
|
||||
pub notes: &'a [LogNote],
|
||||
}
|
||||
|
||||
impl<'a> Account<'a> {
|
||||
/// An account with no commentary — for callers that have no notes.
|
||||
pub fn of(lines: &'a [LogLine]) -> Self {
|
||||
Self { lines, notes: &[] }
|
||||
}
|
||||
}
|
||||
|
||||
/// The game log, newest last, with the empty case spelled out.
|
||||
fn log_section(s: &mut String, log: &[LogLine]) {
|
||||
///
|
||||
/// **The player's own comments are interleaved** (CB-WP-0032) at the
|
||||
/// point they were written, because a remark like *"why did that do
|
||||
/// nothing?"* is about the move above it and loses its subject anywhere
|
||||
/// else.
|
||||
///
|
||||
/// **They are marked as the player's, and cannot be read as game
|
||||
/// events.** A note carries no `who`/`what` and renders in its own
|
||||
/// `.note` block with the player's words in quotes: the log is the
|
||||
/// recorder's vocabulary — what the scenario file will say — and a
|
||||
/// comment that looked like a log line would be a sentence the game never
|
||||
/// produced, sitting in the account of what the game did.
|
||||
fn log_section(s: &mut String, log: Account<'_>) {
|
||||
fn note_block(s: &mut String, n: &LogNote) {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div class=\"note\"><span class=\"k\">you \u{2014} round {round}, \
|
||||
{step}</span><br>\u{201c}{text}\u{201d}</div>",
|
||||
round = n.round,
|
||||
step = esc(&n.step),
|
||||
text = esc(&n.text),
|
||||
);
|
||||
}
|
||||
let notes_after = |k: usize, s: &mut String| {
|
||||
for n in log.notes.iter().filter(|n| n.after == k) {
|
||||
note_block(s, n);
|
||||
}
|
||||
};
|
||||
s.push_str("<h2>log</h2><div class=\"card\" id=\"cb-log\">");
|
||||
if log.is_empty() {
|
||||
if log.lines.is_empty() && log.notes.is_empty() {
|
||||
s.push_str("<span class=\"k\">nothing has happened yet</span>");
|
||||
}
|
||||
for line in log {
|
||||
// Anything said before the first move still belongs in the account.
|
||||
notes_after(0, s);
|
||||
for (i, line) in log.lines.iter().enumerate() {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div><span class=\"k\">{who}</span> {what}",
|
||||
|
|
@ -1327,6 +1395,13 @@ fn log_section(s: &mut String, log: &[LogLine]) {
|
|||
}
|
||||
}
|
||||
s.push_str("</div>");
|
||||
notes_after(i + 1, s);
|
||||
}
|
||||
// A note can outrun the log: post-game notes are written when every
|
||||
// command has been played, and one written against a longer log than
|
||||
// this page has must still appear rather than be silently dropped.
|
||||
for n in log.notes.iter().filter(|n| n.after > log.lines.len()) {
|
||||
note_block(s, n);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
}
|
||||
|
|
@ -1459,7 +1534,7 @@ pub fn ending(
|
|||
message: &str,
|
||||
endpoint: &str,
|
||||
note_to: Option<&str>,
|
||||
log: &[LogLine],
|
||||
log: Account<'_>,
|
||||
series: &[String],
|
||||
) -> String {
|
||||
let mut s = String::with_capacity(4096);
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ mod affordances {
|
|||
mod gamelog {
|
||||
//! CB-WP-0018 T02.
|
||||
|
||||
use crate::doc::{document_with_log, LogLine};
|
||||
use crate::doc::{document_with_log, Account, LogLine};
|
||||
use cb_kernel::PlayerId;
|
||||
|
||||
fn line(effects: &[&str]) -> LogLine {
|
||||
|
|
@ -598,6 +598,104 @@ mod gamelog {
|
|||
}
|
||||
}
|
||||
|
||||
fn note(after: usize, text: &str) -> crate::doc::LogNote {
|
||||
crate::doc::LogNote {
|
||||
after,
|
||||
round: 2,
|
||||
step: "Select".into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0032. A comment appears where it was written.
|
||||
///
|
||||
/// **Position is the whole feature.** A remark like *"why did that do
|
||||
/// nothing?"* is about the move above it; collected at the bottom it
|
||||
/// is a list of sentences with no subjects.
|
||||
#[test]
|
||||
fn comments_appear_in_the_log_where_they_were_written() {
|
||||
let lines = vec![line(&["e0"]), line(&["e1"]), line(&["e2"])];
|
||||
let html = document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
Account {
|
||||
lines: &lines,
|
||||
notes: &[
|
||||
note(0, "before I moved"),
|
||||
note(2, "why did that do nothing"),
|
||||
],
|
||||
},
|
||||
&[],
|
||||
);
|
||||
let text = crate::text_of(&html);
|
||||
let at = |needle: &str| {
|
||||
text.find(needle)
|
||||
.unwrap_or_else(|| panic!("missing {needle:?}"))
|
||||
};
|
||||
// Written before any move: above the first effect.
|
||||
assert!(at("before I moved") < at("e0"), "{text}");
|
||||
// Written after two commands: after the second, before the third.
|
||||
assert!(at("e1") < at("why did that do nothing"), "{text}");
|
||||
assert!(at("why did that do nothing") < at("e2"), "{text}");
|
||||
}
|
||||
|
||||
/// A comment must not be readable as something the game did.
|
||||
///
|
||||
/// The log is the RECORDER's vocabulary — what the scenario file will
|
||||
/// say. A note styled like a log line would be a sentence the game
|
||||
/// never produced, sitting in the account of what the game produced,
|
||||
/// and `ADR-0014 D4` turns on that distinction being visible.
|
||||
#[test]
|
||||
fn a_comment_is_not_dressed_as_a_game_event() {
|
||||
let lines = vec![line(&["e0"])];
|
||||
let html = document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
Account {
|
||||
lines: &lines,
|
||||
notes: &[note(1, "select_action action=SOLVE")],
|
||||
},
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
html.contains("class=\"note\""),
|
||||
"the note had no marking of its own"
|
||||
);
|
||||
let text = crate::text_of(&html);
|
||||
// Attributed to the player, and to a position.
|
||||
assert!(text.contains("you \u{2014} round 2, Select"), "{text}");
|
||||
}
|
||||
|
||||
/// A post-game note is written when every command has been played, so
|
||||
/// its `after` can exceed a log this page happens to be showing. It
|
||||
/// must still appear — a dropped comment is the failure the whole
|
||||
/// note channel exists to avoid.
|
||||
#[test]
|
||||
fn a_comment_past_the_end_of_the_log_is_still_shown() {
|
||||
let html = document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
Account {
|
||||
lines: &[],
|
||||
notes: &[note(9, "so that is why it failed")],
|
||||
},
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
crate::text_of(&html).contains("so that is why it failed"),
|
||||
"a comment was silently dropped"
|
||||
);
|
||||
}
|
||||
|
||||
fn page(log: &[LogLine]) -> String {
|
||||
document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
|
|
@ -605,7 +703,7 @@ mod gamelog {
|
|||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
log,
|
||||
Account::of(log),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
|
@ -1133,7 +1231,7 @@ mod notes {
|
|||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
&[],
|
||||
crate::doc::Account::of(&[]),
|
||||
&[hostile.to_string()],
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -1161,7 +1259,7 @@ mod notes {
|
|||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
&[],
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -1189,7 +1287,7 @@ mod two_columns {
|
|||
crate::TEST_ENDPOINTS,
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
&[],
|
||||
crate::doc::Account::of(&[]),
|
||||
meta,
|
||||
)
|
||||
}
|
||||
|
|
@ -1360,7 +1458,14 @@ mod ending_page {
|
|||
use crate::{doc, jsrun};
|
||||
|
||||
fn page() -> String {
|
||||
doc::ending(None, "the game ended", "/command?t=x", None, &[], &[])
|
||||
doc::ending(
|
||||
None,
|
||||
"the game ended",
|
||||
"/command?t=x",
|
||||
None,
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
/// The label must say what the control DOES. Asserted on the rendered
|
||||
|
|
@ -1439,7 +1544,14 @@ mod ending_page {
|
|||
#[test]
|
||||
fn click_targets_do_not_wear_the_drag_affordance() {
|
||||
let pages = [
|
||||
doc::ending(None, "m", "/command?t=x", None, &[], &[]),
|
||||
doc::ending(
|
||||
None,
|
||||
"m",
|
||||
"/command?t=x",
|
||||
None,
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
),
|
||||
doc::document(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[games_ground::GroundCommand::SelectAction {
|
||||
|
|
@ -1490,7 +1602,14 @@ mod ending_page {
|
|||
o.group_success = false;
|
||||
}
|
||||
let head = |v: &games_ground::view::GroundView| {
|
||||
crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", None, &[], &[]))
|
||||
crate::text_of(&doc::ending(
|
||||
Some(v),
|
||||
"m",
|
||||
"/command?t=x",
|
||||
None,
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
))
|
||||
};
|
||||
assert!(
|
||||
head(&won).contains("game solved"),
|
||||
|
|
@ -1512,7 +1631,7 @@ mod ending_page {
|
|||
"P1 ran out of input",
|
||||
"/command?t=x",
|
||||
None,
|
||||
&[],
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
));
|
||||
assert!(!none.contains("game solved") && !none.contains("game over"));
|
||||
|
|
@ -1526,7 +1645,14 @@ mod ending_page {
|
|||
fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() {
|
||||
let mut v = crate::testfix::view(None);
|
||||
v.mode = games_ground::ScoringMode::SharedGround;
|
||||
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", None, &[], &[]));
|
||||
let text = crate::text_of(&doc::ending(
|
||||
Some(&v),
|
||||
"m",
|
||||
"/command?t=x",
|
||||
None,
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
));
|
||||
|
||||
assert!(
|
||||
text.contains("problems solved"),
|
||||
|
|
@ -1549,7 +1675,14 @@ mod ending_page {
|
|||
fn a_ranked_mode_cites_the_games_own_tiebreak() {
|
||||
let mut v = crate::testfix::view(None);
|
||||
v.mode = games_ground::ScoringMode::BondedCoalitions;
|
||||
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", None, &[], &[]));
|
||||
let text = crate::text_of(&doc::ending(
|
||||
Some(&v),
|
||||
"m",
|
||||
"/command?t=x",
|
||||
None,
|
||||
crate::doc::Account::of(&[]),
|
||||
&[],
|
||||
));
|
||||
|
||||
assert!(
|
||||
text.contains("Lower combined Stress"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue