Land Cut 13: bind an answer to its question with an opaque token (PA.f170)
Two questions asked in one turn carried the same eve::surface_version (asking is conversational and never commits), so the version-only staleness check runtime.rs's world.play arm ran could not tell Q1 and Q2 apart: an answer composed for Q1 was accepted and silently resolved Q2. Each question now mints an opaque random token (play::mint_question_token, uuid v4) that carries no turn id, round, slot, subject, or other world handle. PlayTurn.question_token replaces question_surface_version; OpenQuestion.token replaces OpenQuestion.surface_version. The "Play" button's own props.action carries the token (eve::authenticated_surface), riding DOM-invisibly the same way props.action.command already does — a real client spreads a button's own action fields straight into the payload beside bindings (commandPayload, eve-browser-lowering/src/index.ts:2101). runtime.rs's world.play arm refuses an answer whose answerToken does not name the currently open question's own token; PlayPayload gains answer_token. unwrap_bindings no longer refuses a sibling next to the envelope bindings object outright (PA.f168's original rule) — it merges bindings' own fields with the sibling action fields instead, refusing only a genuine name collision between the two. An unrecognized sibling is still refused, one door downstream, by the operation's own #[serde(deny_unknown_fields)] payload struct. The play-turn store moves to ghostlight.play_turn_store.v3 and reads v1 and v2 leniently, upgrading in place (PlayTurnStore::open, upgrade_legacy_row) the same way Cut 12 upgraded v1 to v2. A recovered row whose question is still open mints a fresh token as part of the upgrade, since no earlier schema recorded one and the card is re-rendered from whatever the row holds. Deleted with the version comparison: question_surface_version and the per-round fresh-snapshot fetch execute_round took solely to compute it (PA.f161/PA.f175's own reason to exist). QuestionId and OpenQuestion survive: QuestionId (turn_id/round/slot) is still play.rs's own internal admission-matching key, and OpenQuestion still carries it plus the question's text — neither existed only to serve the deleted comparison. Soul's own probe is now a passing regression test (soul_an_answer_composed_for_q1_lands_on_q2_the_player_never_saw): two scripted questions in one turn, an answer carrying Q1's own token (read off the served surface) is accepted for Q1 and refused as stale against Q2. Mutation coverage: a missing token, a mismatched token, and Q1's already-spent token replayed against Q2 are each refused. The real client bridge round-trips a token through props.action and a bound text field arriving together in one payload (the_real_client_carries_the_answer_token_and_the_bound_text_together). The committed eve_client_bridge_intents.json fixture needed no regeneration: its own world.play click never has an open question, so its action never carried a token to begin with. crates/ghostlight-dungeon/src/play.rs, crates/ghostlight-dungeon/src/runtime.rs, crates/ghostlight-dungeon/src/eve.rs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1560c35403
commit
2f2a8b671f
3 changed files with 1070 additions and 326 deletions
|
|
@ -491,11 +491,25 @@ pub(crate) fn authenticated_surface(
|
|||
// id), so the server resolves the open question
|
||||
// itself instead of trusting a client-supplied id at
|
||||
// all (`runtime.rs::execute_world`'s `world.play`
|
||||
// arm). PA.f84's guarantee still holds: a stale
|
||||
// `routeHint.sourceVersion` — one that predates the
|
||||
// question the player is answering — is refused
|
||||
// there, so an answer can never land on a question
|
||||
// the player never saw.
|
||||
// arm). PA.f170's guarantee now holds by an opaque
|
||||
// per-question token rather than PA.f161's version
|
||||
// comparison (see `play::PlayTurn::question_token`'s
|
||||
// own doc comment for why a version alone cannot tell
|
||||
// two questions in the same turn apart): the button's
|
||||
// own `props.action` carries it DOM-invisibly, the
|
||||
// way `props.action.command` already does — a real
|
||||
// client spreads a button's own `action` fields
|
||||
// straight into the payload beside `bindings`
|
||||
// (`commandPayload`,
|
||||
// `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`),
|
||||
// never through a renderer that could show or edit
|
||||
// it. `None` while no question is open: the button's
|
||||
// own action then carries no token at all, matching a
|
||||
// plain continue/opening, which needs none.
|
||||
let play_action = play
|
||||
.and_then(|view| view.question.as_ref())
|
||||
.map(|question| json!({"answerToken": question.token}))
|
||||
.unwrap_or_else(|| json!({}));
|
||||
children.extend([
|
||||
json!({
|
||||
"id":"world.play.text",
|
||||
|
|
@ -508,7 +522,7 @@ pub(crate) fn authenticated_surface(
|
|||
"world.play",
|
||||
"Play",
|
||||
"world.play",
|
||||
json!({}),
|
||||
play_action,
|
||||
&["text"],
|
||||
),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -43,20 +43,32 @@ use sha2::{Digest, Sha256};
|
|||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
const STORE_TYPE: &str = "ghostlight.play_turn_store.v1";
|
||||
/// PA.f172: bumped from `ghostlight.play_turn_store.v1` when
|
||||
/// `question_surface_version` was added to `PlayTurn` without
|
||||
/// `#[serde(default)]` — the row's own shape moved, so its schema id moves
|
||||
/// with it. `question_surface_version` now carries `#[serde(default)]`, so
|
||||
/// this v2 reader is a compatible read of a genuine v1 row too: `open` still
|
||||
/// gates strictly on the row's own declared `schema_id` (a fresh legacy
|
||||
/// branch recognizes `LEGACY_STORE_SCHEMA_V1` by name, never silently), but
|
||||
/// once recognized, decodes it with exactly the same struct and upgrades it
|
||||
/// in place rather than discarding the turn it names as unreadable.
|
||||
const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v2";
|
||||
/// PA.f172 bumped this from v1 to v2 when `question_surface_version` was
|
||||
/// added to `PlayTurn` without `#[serde(default)]`. PA.f170 (Cut 13) bumps it
|
||||
/// again, v2 to v3: `question_surface_version` is deleted outright (the
|
||||
/// version-only staleness comparison it served is gone, replaced by the
|
||||
/// opaque per-question `question_token`) and `question_token` carries
|
||||
/// `#[serde(default)]` in its place, for exactly the same reason — a v1 or v2
|
||||
/// row's own bytes never had either field, so the same current struct decodes
|
||||
/// either as `None`. `open` still gates strictly on the row's own declared
|
||||
/// `schema_id` (a legacy branch recognizes `LEGACY_STORE_SCHEMA_V1` and
|
||||
/// `LEGACY_STORE_SCHEMA_V2` by name, never silently), but once recognized,
|
||||
/// upgrades the row to `STORE_SCHEMA` in place rather than discarding the
|
||||
/// turn it names as unreadable. A row recovered this way whose question is
|
||||
/// still open (`state == AwaitingPlayer`) mints a fresh token as part of the
|
||||
/// upgrade — no earlier schema ever recorded one, and the card is re-rendered
|
||||
/// with whatever the row holds, so a player returning to an upgraded session
|
||||
/// can still answer.
|
||||
const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v3";
|
||||
/// The schema id every row written before `question_surface_version` existed
|
||||
/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and
|
||||
/// upgraded to `STORE_SCHEMA` in place (PA.f172).
|
||||
/// upgraded to `STORE_SCHEMA` in place (PA.f172, PA.f170).
|
||||
const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1";
|
||||
/// The schema id every row written between PA.f172 and PA.f170 carries: it
|
||||
/// has `question_surface_version` (now deleted) but not `question_token`.
|
||||
/// Recognized by `PlayTurnStore::open` as legacy, not corrupt, and upgraded
|
||||
/// to `STORE_SCHEMA` in place the same way a v1 row is (PA.f170).
|
||||
const LEGACY_STORE_SCHEMA_V2: &str = "ghostlight.play_turn_store.v2";
|
||||
const STORE_KEY: &str = "primary";
|
||||
|
||||
/// The one round budget: exhausting it closes the turn and narrates, rather
|
||||
|
|
@ -252,23 +264,27 @@ struct PlayTurn {
|
|||
calls: Vec<CallRecord>,
|
||||
persona_turns: Vec<(SubjectId, PersonaTurn)>,
|
||||
question: Option<String>,
|
||||
/// PA.f161: the world's own `eve::surface_version` at the instant this
|
||||
/// question opened (`execute_round`'s `ASK_PLAYER_TOOL` arm), set and
|
||||
/// cleared alongside `question` — never re-derived from whatever the
|
||||
/// world's current revision happens to be when a later request arrives.
|
||||
/// This is what lets an answer be refused as stale by "older than the
|
||||
/// version the question first appeared on" rather than by "not exactly
|
||||
/// today's version," the comparison PA.f161 replaces. `#[serde(default)]`
|
||||
/// (PA.f172, reversing the earlier PA.f95 reasoning here): a row written
|
||||
/// before this field existed — a real shape on a workstation that ran
|
||||
/// Dungeon before Cut 11, not a hypothetical — decodes to `None`, exactly
|
||||
/// what "no question ever recorded a surface version" means for a turn
|
||||
/// this old. `STORE_SCHEMA` moved to v2 alongside this default so the
|
||||
/// canonical re-encode check below still holds current-schema rows to
|
||||
/// their exact bytes; only the legacy v1 read path in
|
||||
/// `PlayTurnStore::open` relies on this default to recover an older row.
|
||||
/// PA.f170 (Cut 13): an opaque random handle minted fresh whenever
|
||||
/// `execute_round`'s `ASK_PLAYER_TOOL` arm opens a question, set and
|
||||
/// cleared alongside `question`. It carries no turn id, round, slot,
|
||||
/// subject, or any other world handle — it is an answer's handle and
|
||||
/// nothing else (invariant 8: reading it in devtools teaches nothing
|
||||
/// about the world). This replaces PA.f161's `question_surface_version`
|
||||
/// and the version-only staleness comparison it served: two questions
|
||||
/// opened in the same turn carry the same surface version (asking is
|
||||
/// conversational and never moves it), so a version comparison alone
|
||||
/// cannot tell them apart — the gap Soul proved for PA.f170. Runtime's
|
||||
/// `world.play` arm refuses an answer whose own token does not name this
|
||||
/// one. `#[serde(default)]`, matching the earlier `question_surface_version`
|
||||
/// reasoning it replaces: a row written under `LEGACY_STORE_SCHEMA_V1` or
|
||||
/// `LEGACY_STORE_SCHEMA_V2` never had this field, so the same current
|
||||
/// struct decodes it to `None` for either — exactly what "no question
|
||||
/// ever recorded a token" means for a turn that old. `PlayTurnStore::open`
|
||||
/// mints a fresh one for a recovered row whose question is still open, so
|
||||
/// an upgraded session stays answerable; the canonical re-encode check
|
||||
/// below still holds current-schema (v3) rows to their exact bytes.
|
||||
#[serde(default)]
|
||||
question_surface_version: Option<u64>,
|
||||
question_token: Option<String>,
|
||||
refusal: Option<String>,
|
||||
narration: Option<String>,
|
||||
/// Set when the turn closed on a fault rather than the player's own
|
||||
|
|
@ -355,20 +371,19 @@ impl PlayTurn {
|
|||
}
|
||||
}
|
||||
|
||||
/// `turn.open_question_id()` paired with the question's own text and the
|
||||
/// surface version it opened on (PA.f134/PA.f161): `None` whenever
|
||||
/// `open_question_id` is, since `turn.question` and
|
||||
/// `turn.question_surface_version` are only ever set alongside it
|
||||
/// (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in the same
|
||||
/// round, and `answer_turn` and `close_with_fault` both clear
|
||||
/// `question`/`question_surface_version` on their own way out of
|
||||
/// `AwaitingPlayer`, PA.f173).
|
||||
/// `turn.open_question_id()` paired with the question's own text and its
|
||||
/// opaque answer token (PA.f134/PA.f170): `None` whenever `open_question_id`
|
||||
/// is, since `turn.question` and `turn.question_token` are only ever set
|
||||
/// alongside it (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in
|
||||
/// the same round, and `answer_turn` and `close_with_fault` both clear
|
||||
/// `question`/`question_token` on their own way out of `AwaitingPlayer`,
|
||||
/// PA.f173).
|
||||
fn open_question_of(turn: &PlayTurn) -> Option<OpenQuestion> {
|
||||
let id = turn.open_question_id()?;
|
||||
Some(OpenQuestion {
|
||||
id,
|
||||
text: turn.question.clone().unwrap_or_default(),
|
||||
surface_version: turn.question_surface_version.unwrap_or(0),
|
||||
token: turn.question_token.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -483,36 +498,38 @@ impl PlayTurnStore {
|
|||
}
|
||||
(row.clone(), state)
|
||||
}
|
||||
// PA.f172: a row written before `question_surface_version`
|
||||
// existed — named legacy, not corrupt, because it is neither: it
|
||||
// is a real, previously-canonical shape this process itself
|
||||
// wrote, under the schema id that was current when it did.
|
||||
// `question_surface_version` now carries `#[serde(default)]`, so
|
||||
// the very same `PlayTurnStoreState` decodes it correctly (the
|
||||
// field reads back `None`, matching what "no question ever
|
||||
// recorded a surface version" means for a turn this old). This
|
||||
// branch upgrades the row to `STORE_SCHEMA` (v2) in place and
|
||||
// persists that upgrade immediately, so the legacy path is only
|
||||
// ever taken once per row; the canonical re-encode check does
|
||||
// not apply here — a v1 row's bytes were never canonical under
|
||||
// v2 and were never meant to be — it resumes for every write
|
||||
// this store makes from here on.
|
||||
// PA.f172/PA.f170: a row written before `question_token` existed
|
||||
// — named legacy, not corrupt, because it is neither: it is a
|
||||
// real, previously-canonical shape this process itself wrote,
|
||||
// under the schema id that was current when it did.
|
||||
// `question_token` carries `#[serde(default)]`, so the very same
|
||||
// `PlayTurnStoreState` decodes it correctly (the field reads back
|
||||
// `None`, matching what "no question ever recorded a token"
|
||||
// means for a turn this old). `upgrade_legacy_row` mints a fresh
|
||||
// token when the recovered turn's own question is still open,
|
||||
// upgrades the row to `STORE_SCHEMA` (v3) in place, and persists
|
||||
// that upgrade immediately, so the legacy path is only ever taken
|
||||
// once per row; the canonical re-encode check does not apply
|
||||
// here — a legacy row's bytes were never canonical under v3 and
|
||||
// were never meant to be — it resumes for every write this store
|
||||
// makes from here on.
|
||||
[row]
|
||||
if row.r#type == STORE_TYPE
|
||||
&& row.key == STORE_KEY
|
||||
&& row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) =>
|
||||
{
|
||||
let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload)
|
||||
.context("play-turn legacy v1 state is corrupt")?;
|
||||
if state.schema != LEGACY_STORE_SCHEMA_V1 {
|
||||
bail!("play-turn legacy v1 state schema is invalid");
|
||||
}
|
||||
state.schema = STORE_SCHEMA.into();
|
||||
let upgraded_row = envelope(&state)?;
|
||||
if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()])? {
|
||||
bail!("play-turn store changed while upgrading a legacy v1 row");
|
||||
}
|
||||
(upgraded_row, state)
|
||||
upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1)?
|
||||
}
|
||||
// PA.f170: a row written between PA.f172 and PA.f170 — it has
|
||||
// `question_surface_version` (deleted, silently ignored on
|
||||
// decode since nothing here reads it) but no `question_token`.
|
||||
// Upgraded exactly like a v1 row above.
|
||||
[row]
|
||||
if row.r#type == STORE_TYPE
|
||||
&& row.key == STORE_KEY
|
||||
&& row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V2) =>
|
||||
{
|
||||
upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V2)?
|
||||
}
|
||||
_ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"),
|
||||
};
|
||||
|
|
@ -631,6 +648,50 @@ fn envelope(state: &PlayTurnStoreState) -> anyhow::Result<CultCacheEnvelope> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Mints a fresh opaque per-question answer token (PA.f170): a random handle
|
||||
/// with no world meaning of its own — not a `QuestionId`, not a subject,
|
||||
/// turn, round, or slot, and forging one only answers the forger's own
|
||||
/// already-open question (invariant 8). `execute_round`'s `ASK_PLAYER_TOOL`
|
||||
/// arm mints one every time a question opens; `upgrade_legacy_row` mints one
|
||||
/// for a recovered legacy row whose question is still open, since no earlier
|
||||
/// schema ever recorded one.
|
||||
fn mint_question_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Shared by `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` and
|
||||
/// `LEGACY_STORE_SCHEMA_V2` branches (PA.f170): decodes `row` under the
|
||||
/// current `PlayTurnStoreState`/`PlayTurn` structs — both legacy schemas
|
||||
/// decode through exactly the same `#[serde(default)]` fields a current-
|
||||
/// schema row would — mints a fresh `question_token` when the recovered
|
||||
/// turn's own question is still open (no earlier schema ever recorded one),
|
||||
/// then upgrades the row to `STORE_SCHEMA` in place and persists that
|
||||
/// upgrade immediately, so the legacy path is only ever taken once per row.
|
||||
fn upgrade_legacy_row(
|
||||
store: &OwnedRedbMessagePackBackingStore,
|
||||
row: &CultCacheEnvelope,
|
||||
legacy_schema: &str,
|
||||
) -> anyhow::Result<(CultCacheEnvelope, PlayTurnStoreState)> {
|
||||
let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload)
|
||||
.with_context(|| format!("play-turn legacy {legacy_schema} state is corrupt"))?;
|
||||
if state.schema != legacy_schema {
|
||||
bail!("play-turn legacy {legacy_schema} state schema is invalid");
|
||||
}
|
||||
if let Some(turn) = state.turn.as_mut()
|
||||
&& turn.state == PlayTurnState::AwaitingPlayer
|
||||
&& turn.question.is_some()
|
||||
&& turn.question_token.is_none()
|
||||
{
|
||||
turn.question_token = Some(mint_question_token());
|
||||
}
|
||||
state.schema = STORE_SCHEMA.into();
|
||||
let upgraded_row = envelope(&state)?;
|
||||
if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()])? {
|
||||
bail!("play-turn store changed while upgrading a legacy {legacy_schema} row");
|
||||
}
|
||||
Ok((upgraded_row, state))
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum PlayError {
|
||||
#[error("the play table is poisoned by a prior kernel invariant fault; restart to clear it")]
|
||||
|
|
@ -687,10 +748,13 @@ pub(crate) enum PlayError {
|
|||
pub(crate) struct OpenQuestion {
|
||||
pub(crate) id: QuestionId,
|
||||
pub(crate) text: String,
|
||||
/// PA.f161: `eve::surface_version` at the moment this question opened —
|
||||
/// the value `runtime.rs`'s `world.play` arm compares an answer's own
|
||||
/// `routeHint.sourceVersion` against, refusing anything older.
|
||||
pub(crate) surface_version: u64,
|
||||
/// PA.f170: the opaque per-question answer token minted when this
|
||||
/// question opened — the value `runtime.rs`'s `world.play` arm compares
|
||||
/// an answer's own `answerToken` against, refusing anything else as
|
||||
/// stale. Replaces PA.f161's `surface_version`, which only ever told
|
||||
/// apart questions that opened on different world revisions, not two
|
||||
/// questions opened in the same turn.
|
||||
pub(crate) token: String,
|
||||
}
|
||||
|
||||
/// One `run` call's own outcome (PA.f109): `Replayed` names the turn a
|
||||
|
|
@ -994,7 +1058,7 @@ impl PlayTable {
|
|||
question: open_question.map(|id| OpenQuestion {
|
||||
id,
|
||||
text: existing.question.clone().unwrap_or_default(),
|
||||
surface_version: existing.question_surface_version.unwrap_or(0),
|
||||
token: existing.question_token.clone().unwrap_or_default(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -1158,7 +1222,7 @@ impl PlayTable {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -1178,7 +1242,7 @@ impl PlayTable {
|
|||
}
|
||||
}
|
||||
turn.question = None;
|
||||
turn.question_surface_version = None;
|
||||
turn.question_token = None;
|
||||
turn.state = PlayTurnState::Running;
|
||||
turn
|
||||
}
|
||||
|
|
@ -1209,7 +1273,7 @@ impl PlayTable {
|
|||
// on `state == AwaitingPlayer`, which a fault always leaves behind by
|
||||
// setting `state = Closed` in the same write.
|
||||
turn.question = None;
|
||||
turn.question_surface_version = None;
|
||||
turn.question_token = None;
|
||||
self.persist(turn).await
|
||||
}
|
||||
|
||||
|
|
@ -1485,25 +1549,16 @@ impl PlayTable {
|
|||
None,
|
||||
);
|
||||
turn.question = Some(question);
|
||||
// PA.f161/PA.f175: the world's own surface version at the
|
||||
// instant this question opens, so a later answer is judged
|
||||
// against the version the question actually first appeared
|
||||
// on, never whatever the world's version happens to be when
|
||||
// the answer arrives. This must be a fresh snapshot taken
|
||||
// right here, not the round-entry `snapshot` above: a round
|
||||
// that authors (a `PLAY_TOOLS` run, committed earlier in this
|
||||
// same `while` loop) and then asks would otherwise record the
|
||||
// version as of before its own commits, not the version the
|
||||
// question actually opened on.
|
||||
let question_snapshot = match self.fetch_round_snapshot().await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(detail) => {
|
||||
self.close_with_fault(turn, detail).await?;
|
||||
return Ok(RoundOutcome::Closed);
|
||||
}
|
||||
};
|
||||
turn.question_surface_version =
|
||||
Some(crate::eve::surface_version(Some(&question_snapshot)));
|
||||
// PA.f170: a fresh opaque token, minted the instant this
|
||||
// question opens, binds an answer to *this* question rather
|
||||
// than to a world revision — PA.f161's `surface_version`
|
||||
// told apart questions that opened on different revisions,
|
||||
// but two questions opened in the same turn share one
|
||||
// revision (asking is conversational and never moves it), so
|
||||
// that comparison alone could not tell them apart. Unlike
|
||||
// the version it replaces, the token needs no snapshot: it
|
||||
// carries no world handle at all.
|
||||
turn.question_token = Some(mint_question_token());
|
||||
turn.state = PlayTurnState::AwaitingPlayer;
|
||||
return Ok(RoundOutcome::AwaitingPlayer);
|
||||
}
|
||||
|
|
@ -3292,52 +3347,41 @@ pub(crate) mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// PA.f175: `execute_round` takes one snapshot at round entry
|
||||
/// (`round_snapshot`) and, before this fix, reused it for every arm in
|
||||
/// that same round — including `ASK_PLAYER_TOOL` — so a round that
|
||||
/// commits an authoring call (a `PLAY_TOOLS` run) and then asks in the
|
||||
/// same round recorded `question_surface_version` from before its own
|
||||
/// commit. A later "is this answer stale" comparison
|
||||
/// (`runtime.rs::an_answer_naming_a_version_older_than_the_question_is_refused_as_stale`)
|
||||
/// judges a version the question's own commit had already passed, so an
|
||||
/// answer naming the version the commit actually landed at — the version
|
||||
/// a real client would have just been served — could be wrongly refused
|
||||
/// as older than the question.
|
||||
/// PA.f170: `execute_round`'s `ASK_PLAYER_TOOL` arm mints a fresh
|
||||
/// `question_token` every time a question opens, never reusing one
|
||||
/// across two distinct questions in the same turn. The version it
|
||||
/// replaces (PA.f161's `surface_version`) could not make this promise:
|
||||
/// asking is conversational and never moves the world's own revision, so
|
||||
/// two questions opened in the same turn — even in two different rounds,
|
||||
/// with nothing committed between them — would have recorded the same
|
||||
/// version. This is the token's own reason to exist: it, not a version,
|
||||
/// is what tells the two questions apart at `runtime.rs`'s own boundary.
|
||||
///
|
||||
/// Mutation: revert `execute_round`'s `ASK_PLAYER_TOOL` arm to record
|
||||
/// `crate::eve::surface_version(Some(&snapshot))` (the round-entry
|
||||
/// snapshot) instead of fetching a fresh one at the moment the question
|
||||
/// opens — this test's asserted version would then equal the version
|
||||
/// from before the round's own `declare_fact` commit, one behind the
|
||||
/// version this test expects.
|
||||
/// Mutation: have `execute_round`'s `ASK_PLAYER_TOOL` arm reuse
|
||||
/// `turn.question_token` when it is already `Some` instead of calling
|
||||
/// `mint_question_token()` unconditionally — this test's two tokens
|
||||
/// would then be equal.
|
||||
#[tokio::test]
|
||||
async fn a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round() {
|
||||
let fixture = play_world(None, "player-author-then-ask").await;
|
||||
async fn two_questions_in_one_turn_mint_two_distinct_tokens() {
|
||||
let fixture = play_world(None, "player-two-questions-distinct-tokens").await;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let world_before = fixture.world.snapshot().await.unwrap();
|
||||
let version_before = crate::eve::surface_version(Some(&world_before));
|
||||
|
||||
let round = output(
|
||||
let ask_one = output(
|
||||
"r0",
|
||||
vec![
|
||||
call_event(
|
||||
"c0",
|
||||
"declare_fact",
|
||||
serde_json::json!({
|
||||
"handle": "ruling",
|
||||
"label": "The Ruling",
|
||||
"statement": "The gate creaks open on its own.",
|
||||
"standing": {"standing": "ruled"},
|
||||
}),
|
||||
),
|
||||
call_event(
|
||||
"c1",
|
||||
ASK_PLAYER_TOOL,
|
||||
serde_json::json!({"question": "Which way?"}),
|
||||
),
|
||||
],
|
||||
vec![call_event(
|
||||
"c0",
|
||||
ASK_PLAYER_TOOL,
|
||||
serde_json::json!({"question": "Which way?"}),
|
||||
)],
|
||||
);
|
||||
let ask_two = output(
|
||||
"r1",
|
||||
vec![call_event(
|
||||
"c1",
|
||||
ASK_PLAYER_TOOL,
|
||||
serde_json::json!({"question": "Are you sure?"}),
|
||||
)],
|
||||
);
|
||||
let personas = PersonaLane::new(
|
||||
ControllerPort::new(fixture.world.clone()),
|
||||
|
|
@ -3349,7 +3393,7 @@ pub(crate) mod tests {
|
|||
let table = PlayTable::new(
|
||||
fixture.world.clone(),
|
||||
personas,
|
||||
ScriptedPort::new(vec![round]),
|
||||
ScriptedPort::new(vec![ask_one, ask_two]),
|
||||
"gpt-5.6-terra".into(),
|
||||
Arc::new(Semaphore::new(2)),
|
||||
&store_path,
|
||||
|
|
@ -3360,40 +3404,43 @@ pub(crate) mod tests {
|
|||
.run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let first_view = table.current_turn_view().await.unwrap();
|
||||
let first_question = first_view.question.clone().expect("Q1 must be open");
|
||||
assert_eq!(first_question.text, "Which way?");
|
||||
|
||||
let asked_view = table.current_turn_view().await.unwrap();
|
||||
let question = asked_view
|
||||
.question
|
||||
.as_ref()
|
||||
.expect("the scripted ask_player call must open a question");
|
||||
table
|
||||
.run(
|
||||
&fixture.principal,
|
||||
test_turn_id(92),
|
||||
PlayRequest {
|
||||
text: "I go left.".into(),
|
||||
answers: Some(first_question.id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let second_view = table.current_turn_view().await.unwrap();
|
||||
let second_question = second_view.question.clone().expect("Q2 must be open");
|
||||
assert_eq!(second_question.text, "Are you sure?");
|
||||
|
||||
let world_after = fixture.world.snapshot().await.unwrap();
|
||||
let version_after = crate::eve::surface_version(Some(&world_after));
|
||||
assert!(
|
||||
version_after > version_before,
|
||||
"declare_fact must have committed and moved the world's own revision \
|
||||
({version_before} -> {version_after})"
|
||||
);
|
||||
assert_eq!(
|
||||
question.surface_version, version_after,
|
||||
"the question's own recorded version must be the version as of when it opened \
|
||||
(after this round's own declare_fact commit, {version_after}), not the \
|
||||
round-entry version {version_before}"
|
||||
assert_ne!(
|
||||
first_question.token, second_question.token,
|
||||
"two distinct questions in the same turn must never share an answer token"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f173: `close_with_fault` used to leave `question` and
|
||||
/// `question_surface_version` set on the turn it closed, though a
|
||||
/// `Closed` turn should never still carry an open question. This was
|
||||
/// unobservable through `current_turn_view`/`open_question_id` only
|
||||
/// because both gate on `state == AwaitingPlayer`, which closing always
|
||||
/// leaves behind in the same write — so this test reaches past that
|
||||
/// gate, at the raw persisted row.
|
||||
/// `question_token` set on the turn it closed, though a `Closed` turn
|
||||
/// should never still carry an open question. This was unobservable
|
||||
/// through `current_turn_view`/`open_question_id` only because both gate
|
||||
/// on `state == AwaitingPlayer`, which closing always leaves behind in
|
||||
/// the same write — so this test reaches past that gate, at the raw
|
||||
/// persisted row.
|
||||
///
|
||||
/// Mutation: remove the `turn.question = None; turn.question_surface_version
|
||||
/// = None;` lines from `close_with_fault` — the persisted row would then
|
||||
/// still carry the question's own text and version after the turn
|
||||
/// closed, and this test's assertions would fail.
|
||||
/// Mutation: remove the `turn.question = None; turn.question_token =
|
||||
/// None;` lines from `close_with_fault` — the persisted row would then
|
||||
/// still carry the question's own text and token after the turn closed,
|
||||
/// and this test's assertions would fail.
|
||||
#[tokio::test]
|
||||
async fn close_with_fault_clears_the_open_question_off_the_persisted_row() {
|
||||
let fixture = play_world(None, "player-fault-clears-question").await;
|
||||
|
|
@ -3434,7 +3481,7 @@ pub(crate) mod tests {
|
|||
|
||||
let mut turn = table.store.lock().await.current().unwrap().clone();
|
||||
assert!(turn.question.is_some());
|
||||
assert!(turn.question_surface_version.is_some());
|
||||
assert!(turn.question_token.is_some());
|
||||
table
|
||||
.close_with_fault(&mut turn, "manufactured fault for PA.f173".into())
|
||||
.await
|
||||
|
|
@ -3447,15 +3494,15 @@ pub(crate) mod tests {
|
|||
"a closed turn must not still carry the question it closed on top of"
|
||||
);
|
||||
assert_eq!(
|
||||
persisted.question_surface_version, None,
|
||||
"a closed turn must not still carry the question's own surface version"
|
||||
persisted.question_token, None,
|
||||
"a closed turn must not still carry the question's own answer token"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field
|
||||
/// minus `question_surface_version` — the shape every row actually
|
||||
/// written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). Test-only:
|
||||
/// no production writer ever mints this shape again.
|
||||
/// minus `question_surface_version`/`question_token` — the shape every
|
||||
/// row actually written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172).
|
||||
/// Test-only: no production writer ever mints this shape again.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LegacyPlayTurnV1 {
|
||||
turn_id: String,
|
||||
|
|
@ -3480,14 +3527,44 @@ pub(crate) mod tests {
|
|||
revision: u64,
|
||||
}
|
||||
|
||||
/// PA.f172: a row written before `question_surface_version` existed —
|
||||
/// under `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process
|
||||
/// A between-PA.f172-and-PA.f170 process's own `PlayTurn`: it has
|
||||
/// `question_surface_version` (deleted by this cut) but not
|
||||
/// `question_token` — the shape every row actually written under
|
||||
/// `LEGACY_STORE_SCHEMA_V2` carries. Test-only: no production writer
|
||||
/// ever mints this shape again.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LegacyPlayTurnV2 {
|
||||
turn_id: String,
|
||||
applied_keys: Vec<String>,
|
||||
opening_prompt: String,
|
||||
player_prose: Vec<String>,
|
||||
rounds: Vec<InferenceOutput>,
|
||||
calls: Vec<CallRecord>,
|
||||
persona_turns: Vec<(SubjectId, PersonaTurn)>,
|
||||
question: Option<String>,
|
||||
question_surface_version: Option<u64>,
|
||||
refusal: Option<String>,
|
||||
narration: Option<String>,
|
||||
fault: Option<String>,
|
||||
state: PlayTurnState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LegacyPlayTurnStoreStateV2 {
|
||||
schema: String,
|
||||
turn: Option<LegacyPlayTurnV2>,
|
||||
ledger: KeyLedger,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// PA.f172: a row written before `question_token` existed — under
|
||||
/// `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process
|
||||
/// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is
|
||||
/// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its
|
||||
/// turn recovered rather than discarded: the missing field decodes to
|
||||
/// `None`, exactly what "no question ever recorded a surface version"
|
||||
/// means for a turn this old. The row is also upgraded to `STORE_SCHEMA`
|
||||
/// in place, durably, so the legacy path is only ever taken once.
|
||||
/// `None`, exactly what "no question ever recorded a token" means for a
|
||||
/// turn this old. The row is also upgraded to `STORE_SCHEMA` in place,
|
||||
/// durably, so the legacy path is only ever taken once.
|
||||
///
|
||||
/// Mutation: remove the `schema_id.as_deref() ==
|
||||
/// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this
|
||||
|
|
@ -3542,8 +3619,9 @@ pub(crate) mod tests {
|
|||
assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111");
|
||||
assert_eq!(recovered.opening_prompt, "You stand at the gate.");
|
||||
assert_eq!(
|
||||
recovered.question_surface_version, None,
|
||||
"a pre-Cut-11 turn never recorded a surface version; the missing field must decode to None"
|
||||
recovered.question_token, None,
|
||||
"a pre-Cut-11 turn never recorded a token, and its own question is not open (Running); \
|
||||
the missing field must decode to None and no token must be minted for it"
|
||||
);
|
||||
assert_eq!(
|
||||
store.row.schema_id.as_deref(),
|
||||
|
|
@ -3563,6 +3641,152 @@ pub(crate) mod tests {
|
|||
assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id);
|
||||
}
|
||||
|
||||
/// PA.f170: the same recognition and in-place upgrade `a_legacy_v1_row_*`
|
||||
/// proves, for a row written between PA.f172 and PA.f170 instead — it
|
||||
/// carries `question_surface_version` (now deleted, silently ignored on
|
||||
/// decode) but not `question_token`.
|
||||
///
|
||||
/// Mutation: remove the `schema_id.as_deref() ==
|
||||
/// Some(LEGACY_STORE_SCHEMA_V2)` arm from `PlayTurnStore::open` — this
|
||||
/// row would then fall through to the catch-all and `open` would return
|
||||
/// `Err`, silently disabling play on any workstation that ran Dungeon
|
||||
/// between Cut 11 and Cut 13.
|
||||
#[tokio::test]
|
||||
async fn a_legacy_v2_row_is_recognized_and_upgraded_not_refused_as_corrupt() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let legacy_turn = LegacyPlayTurnV2 {
|
||||
turn_id: "22222222-2222-2222-2222-222222222222".into(),
|
||||
applied_keys: vec!["key-b".into()],
|
||||
opening_prompt: "You stand at the gate.".into(),
|
||||
player_prose: vec!["I knock.".into()],
|
||||
rounds: Vec::new(),
|
||||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
state: PlayTurnState::Running,
|
||||
};
|
||||
let legacy_state = LegacyPlayTurnStoreStateV2 {
|
||||
schema: LEGACY_STORE_SCHEMA_V2.into(),
|
||||
turn: Some(legacy_turn),
|
||||
ledger: KeyLedger::default(),
|
||||
revision: 4,
|
||||
};
|
||||
let legacy_row = CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec_named(&legacy_state).unwrap(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(LEGACY_STORE_SCHEMA_V2.into()),
|
||||
};
|
||||
{
|
||||
let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
assert!(
|
||||
raw_store
|
||||
.compare_and_swap_batch(&[], vec![legacy_row])
|
||||
.unwrap(),
|
||||
"seeding the legacy row must succeed against an empty store"
|
||||
);
|
||||
}
|
||||
|
||||
let store = PlayTurnStore::open(&store_path)
|
||||
.expect("a genuine v2 row must open, not be refused as corrupt");
|
||||
let recovered = store.current().expect("the legacy row's own turn must be recovered");
|
||||
assert_eq!(recovered.turn_id, "22222222-2222-2222-2222-222222222222");
|
||||
assert_eq!(
|
||||
recovered.question_token, None,
|
||||
"a v2 turn never recorded a token, and its own question is not open (Running); \
|
||||
the missing field must decode to None and no token must be minted for it"
|
||||
);
|
||||
assert_eq!(
|
||||
store.row.schema_id.as_deref(),
|
||||
Some(STORE_SCHEMA),
|
||||
"the legacy row must be upgraded to the current schema in place, not left at v2"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f170: an upgraded row whose question is still open gets a freshly
|
||||
/// minted token as part of the upgrade — no earlier schema ever recorded
|
||||
/// one, and the card is re-rendered with whatever the row holds, so a
|
||||
/// player returning to an upgraded session (Raven's workstation, named in
|
||||
/// the cut's own spec) must still be able to answer.
|
||||
///
|
||||
/// Mutation: remove the mint-if-open branch from `upgrade_legacy_row` —
|
||||
/// the recovered turn's own `question_token` would then stay `None` while
|
||||
/// `AwaitingPlayer`, and the answer this test goes on to submit would be
|
||||
/// refused as stale by `runtime.rs`'s own boundary (no token to match)
|
||||
/// even though the question is genuinely still open.
|
||||
#[tokio::test]
|
||||
async fn an_upgraded_legacy_row_whose_question_is_still_open_mints_a_fresh_token() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store_path = directory.path().join("play-turn-v1.cc");
|
||||
|
||||
let legacy_turn = LegacyPlayTurnV1 {
|
||||
turn_id: "33333333-3333-3333-3333-333333333333".into(),
|
||||
applied_keys: vec!["key-c".into()],
|
||||
opening_prompt: "You stand at the gate.".into(),
|
||||
player_prose: vec!["I knock.".into()],
|
||||
rounds: vec![output(
|
||||
"r0",
|
||||
vec![call_event(
|
||||
"c0",
|
||||
ASK_PLAYER_TOOL,
|
||||
serde_json::json!({"question": "Which way?"}),
|
||||
)],
|
||||
)],
|
||||
calls: vec![CallRecord {
|
||||
call_id: "c0".into(),
|
||||
round: 0,
|
||||
slot: 0,
|
||||
body: Some(RecordedCall::AskPlayer("Which way?".into())),
|
||||
result: None,
|
||||
}],
|
||||
persona_turns: Vec::new(),
|
||||
question: Some("Which way?".into()),
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
state: PlayTurnState::AwaitingPlayer,
|
||||
};
|
||||
let legacy_state = LegacyPlayTurnStoreStateV1 {
|
||||
schema: LEGACY_STORE_SCHEMA_V1.into(),
|
||||
turn: Some(legacy_turn),
|
||||
ledger: KeyLedger::default(),
|
||||
revision: 5,
|
||||
};
|
||||
let legacy_row = CultCacheEnvelope {
|
||||
key: STORE_KEY.into(),
|
||||
r#type: STORE_TYPE.into(),
|
||||
payload: rmp_serde::to_vec_named(&legacy_state).unwrap(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()),
|
||||
};
|
||||
{
|
||||
let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
|
||||
assert!(
|
||||
raw_store
|
||||
.compare_and_swap_batch(&[], vec![legacy_row])
|
||||
.unwrap(),
|
||||
"seeding the legacy row must succeed against an empty store"
|
||||
);
|
||||
}
|
||||
|
||||
let store = PlayTurnStore::open(&store_path)
|
||||
.expect("a genuine pre-Cut-11 row must open, not be refused as corrupt");
|
||||
let recovered = store.current().expect("the legacy row's own turn must be recovered");
|
||||
assert_eq!(recovered.state, PlayTurnState::AwaitingPlayer);
|
||||
let token = recovered
|
||||
.question_token
|
||||
.clone()
|
||||
.expect("an upgraded row whose question is still open must mint a fresh token");
|
||||
assert!(!token.is_empty(), "a minted token must not be the empty-string fallback");
|
||||
}
|
||||
|
||||
/// PA.f172's own negative: a row that matches the current schema id but
|
||||
/// whose bytes do not decode at all is still refused as corrupt — the
|
||||
/// legacy path recognizes only the one schema id every pre-Cut-11
|
||||
|
|
@ -3631,7 +3855,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -5085,7 +5309,7 @@ pub(crate) mod tests {
|
|||
}],
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -5211,7 +5435,7 @@ pub(crate) mod tests {
|
|||
}],
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -5290,7 +5514,7 @@ pub(crate) mod tests {
|
|||
}],
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -6069,7 +6293,7 @@ pub(crate) mod tests {
|
|||
}],
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -6760,7 +6984,7 @@ pub(crate) mod tests {
|
|||
// need a `persona_turns` entry here.
|
||||
persona_turns: vec![(a, fake_persona_turn("a acted")), (b, fake_persona_turn("b acted"))],
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -6794,7 +7018,7 @@ pub(crate) mod tests {
|
|||
}],
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -6969,7 +7193,7 @@ pub(crate) mod tests {
|
|||
// speak call below quotes from.
|
||||
persona_turns: vec![(mara_subject, fake_persona_turn(QUOTE))],
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -7309,7 +7533,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: vec![(mara, fake_persona_turn(PROSE))],
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -7645,7 +7869,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -8099,7 +8323,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -8200,7 +8424,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -8250,7 +8474,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -8318,7 +8542,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: Some("The end.".into()),
|
||||
fault: None,
|
||||
|
|
@ -8736,7 +8960,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -9364,7 +9588,7 @@ pub(crate) mod tests {
|
|||
}],
|
||||
persona_turns: vec![(mara, fake_persona_turn(PROSE))],
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -9480,7 +9704,7 @@ pub(crate) mod tests {
|
|||
],
|
||||
persona_turns: vec![(mara, fake_persona_turn(PROSE))],
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
@ -9770,7 +9994,7 @@ pub(crate) mod tests {
|
|||
calls: Vec::new(),
|
||||
persona_turns: Vec::new(),
|
||||
question: None,
|
||||
question_surface_version: None,
|
||||
question_token: None,
|
||||
refusal: None,
|
||||
narration: None,
|
||||
fault: None,
|
||||
|
|
|
|||
|
|
@ -149,29 +149,43 @@ struct CreatePayload {
|
|||
/// `world.activate`, whose actions capture nothing) passes through
|
||||
/// unchanged — `commandPayload` on the lowering's own side never sets
|
||||
/// `bindings` when `captureBindings` is empty.
|
||||
/// Refuses a payload that carries both the envelope `bindings` object and
|
||||
/// flat sibling fields (PA.f168): a real client's payload is `{"bindings":
|
||||
/// {...}}` alone (`commandPayload` on the lowering's own side never adds a
|
||||
/// sibling), so a sibling next to `bindings` names a shape nothing produces
|
||||
/// on purpose — silently keeping only `bindings` would discard it instead of
|
||||
/// surfacing the mismatch.
|
||||
fn unwrap_bindings(payload: &Value) -> Result<&Value, String> {
|
||||
/// PA.f168's original rule refused any sibling next to the envelope
|
||||
/// `bindings` object outright. PA.f170 changes what a real client's payload
|
||||
/// carries: a button's own `props.action` fields (Cut 13's per-question
|
||||
/// answer token, for `world.play`) ride the *same* top-level payload object
|
||||
/// `commandPayload` builds — `{...action, bindings: {...captured}}` — so a
|
||||
/// sibling next to `bindings` is now the shape the real lowering produces on
|
||||
/// purpose, not a mismatch to surface. This merges the two into one flat
|
||||
/// object instead: `bindings`' own fields plus every sibling field, so a
|
||||
/// payload struct's ordinary `#[serde(deny_unknown_fields)]` still catches a
|
||||
/// genuinely unknown field exactly as it did before. A field named by both
|
||||
/// `bindings` and a sibling is refused outright — that shape nothing on the
|
||||
/// real client's own side produces, and silently preferring one over the
|
||||
/// other would hide the collision instead of surfacing it.
|
||||
fn unwrap_bindings(payload: &Value) -> Result<Value, String> {
|
||||
let Some(object) = payload.as_object() else {
|
||||
return Ok(payload);
|
||||
return Ok(payload.clone());
|
||||
};
|
||||
match object.get("bindings") {
|
||||
Some(bindings) if bindings.is_object() => {
|
||||
if object.len() != 1 {
|
||||
return Err(
|
||||
"payload carries an envelope 'bindings' object alongside flat sibling \
|
||||
fields; send one or the other, never both"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(bindings)
|
||||
let Some(bindings_object) = object.get("bindings").and_then(Value::as_object) else {
|
||||
return Ok(payload.clone());
|
||||
};
|
||||
let mut merged = serde_json::Map::with_capacity(object.len() + bindings_object.len());
|
||||
for (key, value) in object {
|
||||
if key == "bindings" {
|
||||
continue;
|
||||
}
|
||||
_ => Ok(payload),
|
||||
if bindings_object.contains_key(key) {
|
||||
return Err(format!(
|
||||
"payload's envelope 'bindings' object and its own sibling action field both \
|
||||
name '{key}'; send it in exactly one place"
|
||||
));
|
||||
}
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
for (key, value) in bindings_object {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
Ok(Value::Object(merged))
|
||||
}
|
||||
|
||||
/// Cut 10 (PA.f149): every one of these three fields is edited in a plain
|
||||
|
|
@ -229,19 +243,29 @@ fn is_handle_shape(value: &str) -> bool {
|
|||
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// `world.play`'s own payload (Cut 8b): the player's prose alone. Cut 10
|
||||
/// (PA.f151) deleted the client-supplied `answers` field: the vendored Eve
|
||||
/// browser lowering has no authored, non-editable binding value (`hidden` is
|
||||
/// not a prop any renderer reads), so the id-carrying control it used to
|
||||
/// name would have rendered as a plain, visible, editable text box holding
|
||||
/// the raw `QuestionId` — a leak, and once editable, a forgeable one. The
|
||||
/// server resolves the currently open question itself now
|
||||
/// (`execute_world`'s `world.play` arm), refusing a stale
|
||||
/// `routeHint.sourceVersion` instead of trusting a client-named id.
|
||||
/// `world.play`'s own payload. Cut 10 (PA.f151) deleted the client-supplied
|
||||
/// `answers` field: the vendored Eve browser lowering has no authored,
|
||||
/// non-editable binding value (`hidden` is not a prop any renderer reads), so
|
||||
/// the id-carrying control it used to name would have rendered as a plain,
|
||||
/// visible, editable text box holding the raw `QuestionId` — a leak, and once
|
||||
/// editable, a forgeable one. The server still resolves the currently open
|
||||
/// question itself (`execute_world`'s `world.play` arm), but PA.f170 replaces
|
||||
/// the version-only staleness check that once stood in for identity:
|
||||
/// `answer_token` is the opaque per-question token `play::ASK_PLAYER_TOOL`
|
||||
/// mints when the question opens, carried DOM-invisibly in the "Play"
|
||||
/// button's own `props.action` (`eve::authenticated_surface`) rather than
|
||||
/// through a `captureBindings` control — the real lowering spreads a button's
|
||||
/// `action` fields straight into the payload alongside `bindings`
|
||||
/// (`commandPayload`, `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`).
|
||||
/// Absent or not naming the currently open question's own token, the answer
|
||||
/// is refused as stale, never applied to whatever question happens to be
|
||||
/// open when the request is processed.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct PlayPayload {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
answer_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -1199,7 +1223,7 @@ async fn execute_world(
|
|||
.unwrap_or(""),
|
||||
)?;
|
||||
if invocation.operation.operation_id == "world.create" {
|
||||
let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?.clone())
|
||||
let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?)
|
||||
.map_err(|error| RuntimeCommandError::Payload(error.to_string()))?;
|
||||
if let Some(root) = payload
|
||||
.jurisdictions
|
||||
|
|
@ -1259,24 +1283,25 @@ async fn execute_world(
|
|||
"only the world's owner may play".into(),
|
||||
));
|
||||
}
|
||||
let payload: PlayPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?.clone())
|
||||
let payload: PlayPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?)
|
||||
.map_err(|error| RuntimeCommandError::Payload(error.to_string()))?;
|
||||
let table = state
|
||||
.play
|
||||
.clone()
|
||||
.ok_or_else(|| RuntimeCommandError::Payload("the play table is unavailable".into()))?;
|
||||
// The server resolves the open question itself — the client no
|
||||
// longer supplies one at all. No question open: this is a plain
|
||||
// longer names it by `QuestionId`. No question open: this is a plain
|
||||
// continue/opening, `answers: None`. A question open: the caller
|
||||
// must be answering it. PA.f84's guarantee (an answer cannot land on
|
||||
// a question the player never saw) holds by refusing a
|
||||
// `routeHint.sourceVersion` older than `question.surface_version` —
|
||||
// the world's own `eve::surface_version` at the instant this
|
||||
// question opened (`play.rs`'s `execute_round`, PA.f161) — the only
|
||||
// comparison this makes. A real client always echoes the most recent
|
||||
// `version` it was actually served, so this can only refuse a
|
||||
// genuinely stale answer, never one served after the question
|
||||
// appeared, regardless of how many world commits landed in between.
|
||||
// must be answering it, and PA.f170's guarantee (an answer cannot
|
||||
// land on a question the player never saw) is judged by the opaque
|
||||
// per-question token the question's own render carried in the "Play"
|
||||
// button's `props.action` (`eve::authenticated_surface`), not by
|
||||
// `routeHint.sourceVersion`: two questions opened in the same turn
|
||||
// carry the same surface version (asking is conversational and never
|
||||
// moves it), so a version-only comparison cannot tell them apart —
|
||||
// exactly the gap Soul proved (PA.f170). A token that is absent, or
|
||||
// does not name the currently open question's own token, is refused
|
||||
// as stale.
|
||||
let view = table.current_turn_view().await;
|
||||
// PA.f155: `PlayTurnView::state` is the actual rule this branches
|
||||
// on — the turn is currently waiting on the player — rather than
|
||||
|
|
@ -1289,11 +1314,10 @@ async fn execute_world(
|
|||
let answers = match view.as_ref().and_then(|view| view.question.as_ref()).filter(|_| is_awaiting_player) {
|
||||
None => None,
|
||||
Some(question) => {
|
||||
let source_version = invocation.operation.route_hint.source_version.unwrap_or(0);
|
||||
if source_version < question.surface_version {
|
||||
if payload.answer_token.as_deref() != Some(question.token.as_str()) {
|
||||
return Err(RuntimeCommandError::Payload(format!(
|
||||
"the answer is stale: it names a surface older than the one the question \
|
||||
\"{}\" first appeared on",
|
||||
"the answer is stale: its token does not name the question \"{}\" \
|
||||
currently open",
|
||||
question.text
|
||||
)));
|
||||
}
|
||||
|
|
@ -1365,17 +1389,17 @@ async fn execute_world(
|
|||
})?;
|
||||
let body = match invocation.operation.operation_id.as_str() {
|
||||
"world.approve" => {
|
||||
serde_json::from_value::<EmptyPayload>(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?.clone())
|
||||
serde_json::from_value::<EmptyPayload>(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?)
|
||||
.map_err(|error| RuntimeCommandError::Payload(error.to_string()))?;
|
||||
CommandBody::ApproveDraft
|
||||
}
|
||||
"world.activate" => {
|
||||
serde_json::from_value::<EmptyPayload>(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?.clone())
|
||||
serde_json::from_value::<EmptyPayload>(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?)
|
||||
.map_err(|error| RuntimeCommandError::Payload(error.to_string()))?;
|
||||
CommandBody::ActivateWorld
|
||||
}
|
||||
"world.advance_time" => {
|
||||
let payload: AdvanceTimePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?.clone())
|
||||
let payload: AdvanceTimePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?)
|
||||
.map_err(|error| RuntimeCommandError::Payload(error.to_string()))?;
|
||||
CommandBody::AdvanceTime {
|
||||
minutes: TickMinutes::new(payload.minutes).ok_or_else(|| {
|
||||
|
|
@ -1387,7 +1411,7 @@ async fn execute_world(
|
|||
// through its own port, as the owner, and the receipt reports what one
|
||||
// session did rather than what one command committed.
|
||||
"world.seed" => {
|
||||
let payload: SeedPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?.clone())
|
||||
let payload: SeedPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?)
|
||||
.map_err(|error| RuntimeCommandError::Payload(error.to_string()))?;
|
||||
let outcome = seed_once(state, verified_principal, &snapshot, payload).await?;
|
||||
let after = current_world(state)
|
||||
|
|
@ -2882,6 +2906,107 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// PA.f170's own round trip through the real client: with a question
|
||||
/// open, the "Play" button's own `props.action` (the answer token) and
|
||||
/// the bound `text` field must arrive together in one payload —
|
||||
/// `{"answerToken":..., "bindings":{"text":...}}` — exactly the shape
|
||||
/// `commandPayload` produces by spreading a button's own action fields
|
||||
/// into the payload beside `bindings`
|
||||
/// (`vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`). This
|
||||
/// is not in the committed fixture above (that walkthrough's own
|
||||
/// `world.play` click never has an open question, so its action carries
|
||||
/// no token to prove); this test builds its own scripted question so the
|
||||
/// merge actually has something in `props.action` to carry. Skips, with
|
||||
/// a message, exactly when the fixture-matching test above does.
|
||||
#[tokio::test]
|
||||
async fn the_real_client_carries_the_answer_token_and_the_bound_text_together() {
|
||||
if !eve_client_bridge_is_available() {
|
||||
eprintln!(
|
||||
"SKIPPED the_real_client_carries_the_answer_token_and_the_bound_text_together: \
|
||||
`node` or the vendored lowering's built `dist/index.js` is not available in this \
|
||||
environment, so this test did not drive the real Eve client bridge."
|
||||
);
|
||||
return;
|
||||
}
|
||||
let (fixture, served_version) = play_world_with_a_scripted_question().await;
|
||||
let table = fixture.state.play.clone().unwrap();
|
||||
|
||||
let played = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"I stand at a crossroads."}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(played["state"], "accepted");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await
|
||||
&& view.state == PlayTurnState::AwaitingPlayer
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the scripted ask_player call must open a question");
|
||||
|
||||
let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await;
|
||||
let surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await;
|
||||
let play_intent = client_intents(
|
||||
&surface,
|
||||
&provider,
|
||||
json!([
|
||||
{"set": {"world.play.text": "I go left."}},
|
||||
{"click": "world.play"}
|
||||
]),
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let payload = play_intent["payload"].clone();
|
||||
assert!(
|
||||
payload
|
||||
.get("answerToken")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|token| !token.is_empty()),
|
||||
"the real client's own button action must carry a non-empty answerToken: {payload}"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["bindings"]["text"], "I go left.",
|
||||
"the real client's own captured binding must still carry the bound text beside the \
|
||||
action's own answerToken: {payload}"
|
||||
);
|
||||
|
||||
let answered = post(&fixture.state, &fixture.cookie, play_intent).await;
|
||||
assert_eq!(
|
||||
answered["state"], "accepted",
|
||||
"the real client's own combined payload must be accepted: {answered}"
|
||||
);
|
||||
|
||||
let closed = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await {
|
||||
if view.state == PlayTurnState::Closed {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the scripted end_turn call must close the turn");
|
||||
assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet."));
|
||||
}
|
||||
|
||||
/// PA.f164: `targets`, `jurisdictions`, and `brief` are all documented
|
||||
/// "required, may be empty" (`CreatePayload`) — a legitimate deliberate
|
||||
/// choice, not a value nobody supplied. Before this fix, only `lens_weights`
|
||||
|
|
@ -2939,17 +3064,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// PA.f168: `unwrap_bindings` refuses a payload carrying both the
|
||||
/// envelope `bindings` object and a flat sibling field, rather than
|
||||
/// silently keeping only `bindings` and discarding the sibling. No real
|
||||
/// client sends this shape (`commandPayload` on the lowering's own side
|
||||
/// never adds one), so this exercises the refusal directly.
|
||||
/// PA.f168 originally refused any sibling next to the envelope
|
||||
/// `bindings` object outright. PA.f170 changes what a real client's
|
||||
/// payload carries — a button's own `props.action` fields (the answer
|
||||
/// token, for `world.play`) ride beside `bindings` on purpose — so
|
||||
/// `unwrap_bindings` now merges the two instead. An unrecognized sibling
|
||||
/// is still refused, just one door downstream: the merged object still
|
||||
/// has to satisfy the operation's own payload struct, and
|
||||
/// `world.approve`'s `EmptyPayload` (`#[serde(deny_unknown_fields)]`)
|
||||
/// declares none at all, so `"rogue"` is still an unknown field there.
|
||||
///
|
||||
/// Mutation: revert `unwrap_bindings` to its old
|
||||
/// `.filter(|bindings| bindings.is_object()).unwrap_or(payload)` shape —
|
||||
/// this command would then be accepted, silently discarding `"rogue"`.
|
||||
/// Mutation: have `unwrap_bindings` silently drop an unrecognized sibling
|
||||
/// instead of merging it in — this command would then be accepted, with
|
||||
/// `"rogue"` discarded rather than refused.
|
||||
#[tokio::test]
|
||||
async fn a_payload_with_a_sibling_field_beside_bindings_is_refused() {
|
||||
async fn a_payload_with_an_unrecognized_sibling_field_beside_bindings_is_refused() {
|
||||
let fixture = fixture().await;
|
||||
let created = post(
|
||||
&fixture.state,
|
||||
|
|
@ -2986,11 +3115,77 @@ mod tests {
|
|||
.await;
|
||||
assert_eq!(denied["state"], "denied");
|
||||
assert!(
|
||||
denied["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("bindings"),
|
||||
"a sibling field beside the envelope must be refused, not silently discarded: {denied}"
|
||||
denied["message"].as_str().unwrap_or_default().contains("rogue"),
|
||||
"an unrecognized sibling field beside the envelope must still be refused, not silently \
|
||||
discarded: {denied}"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f170's own negative on the merge itself: a payload naming the same
|
||||
/// field both inside `bindings` and as a sibling of it — a shape nothing
|
||||
/// on the real lowering's own side produces (`commandPayload` builds
|
||||
/// `bindings` and the action's own fields from disjoint sources) — is
|
||||
/// refused as an outright collision, never resolved by silently
|
||||
/// preferring one side over the other.
|
||||
///
|
||||
/// Mutation: have `unwrap_bindings` prefer the sibling (or the `bindings`
|
||||
/// entry) on a name collision instead of refusing it — this command
|
||||
/// would then be accepted with one of the two conflicting `"minutes"`
|
||||
/// values silently discarded.
|
||||
#[tokio::test]
|
||||
async fn a_payload_naming_the_same_field_in_bindings_and_as_a_sibling_is_refused() {
|
||||
let fixture = fixture().await;
|
||||
let created = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.create",
|
||||
"ghostlight.world_create.v4",
|
||||
0,
|
||||
json!({
|
||||
"title":"Colliding Field World",
|
||||
"brief":"",
|
||||
"subject_label":"Operator",
|
||||
"targets":{},
|
||||
"jurisdictions":[],
|
||||
"lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1}
|
||||
}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(created["state"], "accepted");
|
||||
let approved = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(approved["state"], "accepted");
|
||||
let activated = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(activated["state"], "accepted");
|
||||
|
||||
let denied = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.advance_time",
|
||||
"ghostlight.world_advance_time.v0",
|
||||
3,
|
||||
json!({"bindings":{"minutes":5}, "minutes":9}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied["state"], "denied");
|
||||
assert!(
|
||||
denied["message"].as_str().unwrap_or_default().contains("minutes"),
|
||||
"a field named by both bindings and a sibling must be refused as a collision: {denied}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3254,17 +3449,12 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// PA.f161's own harness: builds a play world through the real HTTP
|
||||
/// route, then swaps `state.play` for a table over `crate::play::tests`'
|
||||
/// own `ScriptedPort`, scripted to call `ask_player` and then `end_turn`
|
||||
/// — a deterministic question this test can actually answer through the
|
||||
/// route, which the fixture's own unreachable test inference organ
|
||||
/// cannot produce. Returns the fixture and the world's `sourceVersion`
|
||||
/// after activation (the version `world.play`'s own staleness check
|
||||
/// compares an answer against, since asking a question is conversational
|
||||
/// and moves nothing on the world).
|
||||
async fn play_world_with_a_scripted_question() -> (Fixture, u64) {
|
||||
use crate::play::tests::{ScriptedPort, call_event, output, text_event};
|
||||
/// The create/approve/activate boilerplate every scripted-question
|
||||
/// harness below needs, factored out so `play_world_with_a_scripted_question`
|
||||
/// and `play_world_with_two_scripted_questions` (Cut 13) do not each
|
||||
/// repeat it. Returns the fixture and the world's `sourceVersion` after
|
||||
/// activation.
|
||||
async fn activated_fixture(title: &str) -> (Fixture, u64) {
|
||||
let fixture = fixture().await;
|
||||
let created = post(
|
||||
&fixture.state,
|
||||
|
|
@ -3274,7 +3464,7 @@ mod tests {
|
|||
"ghostlight.world_create.v4",
|
||||
0,
|
||||
json!({
|
||||
"title":"Scripted Question World",
|
||||
"title":title,
|
||||
"brief":"",
|
||||
"subject_label":"Operator",
|
||||
"targets":{},
|
||||
|
|
@ -3301,6 +3491,21 @@ mod tests {
|
|||
.await;
|
||||
assert_eq!(activated["state"], "accepted");
|
||||
let served_version = activated["sourceVersion"].as_u64().unwrap();
|
||||
(fixture, served_version)
|
||||
}
|
||||
|
||||
/// PA.f161's own harness: builds a play world through the real HTTP
|
||||
/// route, then swaps `state.play` for a table over `crate::play::tests`'
|
||||
/// own `ScriptedPort`, scripted to call `ask_player` and then `end_turn`
|
||||
/// — a deterministic question this test can actually answer through the
|
||||
/// route, which the fixture's own unreachable test inference organ
|
||||
/// cannot produce. Returns the fixture and the world's `sourceVersion`
|
||||
/// after activation (still a required `routeHint.sourceVersion` field on
|
||||
/// every command envelope, even though `world.play` no longer reads it
|
||||
/// for staleness — PA.f170 judges an answer by its own token instead).
|
||||
async fn play_world_with_a_scripted_question() -> (Fixture, u64) {
|
||||
use crate::play::tests::{ScriptedPort, call_event, output, text_event};
|
||||
let (fixture, served_version) = activated_fixture("Scripted Question World").await;
|
||||
|
||||
let ask = output(
|
||||
"r0",
|
||||
|
|
@ -3334,19 +3539,83 @@ mod tests {
|
|||
(fixture, served_version)
|
||||
}
|
||||
|
||||
/// PA.f161's own end-to-end proof: an answer whose `routeHint.sourceVersion`
|
||||
/// names the version a real client actually served the question on — the
|
||||
/// world's own `surface_version` at the instant it opened, unmoved since
|
||||
/// (asking a question is conversational) — is accepted, and the turn
|
||||
/// closes with the scripted narration.
|
||||
///
|
||||
/// Mutation: change `source_version < question.surface_version` to
|
||||
/// `source_version <= question.surface_version` at `runtime.rs`'s
|
||||
/// `world.play` arm — this test's own answer, echoing exactly
|
||||
/// `question.surface_version`, would then be refused as stale, failing
|
||||
/// the `accepted` assertion below.
|
||||
#[tokio::test]
|
||||
async fn an_answer_naming_the_version_the_question_actually_opened_on_is_accepted() {
|
||||
/// PA.f170's own harness: two scripted `ask_player` calls, one per round,
|
||||
/// with no `end_turn` — so answering Q1 leaves the turn `AwaitingPlayer`
|
||||
/// on Q2 rather than closed, the exact shape Soul's own probe needed to
|
||||
/// show an answer composed for Q1 landing on Q2.
|
||||
async fn play_world_with_two_scripted_questions() -> (Fixture, u64) {
|
||||
use crate::play::tests::{ScriptedPort, call_event, output};
|
||||
let (fixture, served_version) = activated_fixture("Two Scripted Questions World").await;
|
||||
|
||||
let ask_one = output(
|
||||
"r0",
|
||||
vec![call_event("c0", "ask_player", serde_json::json!({"question": "Which way?"}))],
|
||||
);
|
||||
let ask_two = output(
|
||||
"r1",
|
||||
vec![call_event("c1", "ask_player", serde_json::json!({"question": "Are you sure?"}))],
|
||||
);
|
||||
let personas = PersonaLane::new(
|
||||
ControllerPort::new(fixture.state.world.clone()),
|
||||
ScriptedPort::new(vec![]),
|
||||
"gpt-5.6-sol".into(),
|
||||
"gpt-5.6-sol".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let table = PlayTable::new(
|
||||
fixture.state.world.clone(),
|
||||
personas,
|
||||
ScriptedPort::new(vec![ask_one, ask_two]),
|
||||
"gpt-5.6-terra".into(),
|
||||
Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)),
|
||||
directory.path().join("play-turn-v1.cc"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut fixture = fixture;
|
||||
fixture.state.play = Some(Arc::new(table));
|
||||
fixture._play_directory = Some(directory);
|
||||
(fixture, served_version)
|
||||
}
|
||||
|
||||
/// Finds the surface node named `id`, depth-first, the same way
|
||||
/// `crate::play::tests::find_surface_node` does for `play.rs`'s own
|
||||
/// tests — duplicated locally rather than exposed `pub(crate)` across a
|
||||
/// module boundary for one small helper.
|
||||
fn find_surface_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> {
|
||||
if node.get("id").and_then(Value::as_str) == Some(id) {
|
||||
return Some(node);
|
||||
}
|
||||
for child in node.get("children").and_then(Value::as_array).into_iter().flatten() {
|
||||
if let Some(found) = find_surface_node(child, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reads the open question's own answer token the same way a real
|
||||
/// client does (PA.f170): off the "Play" button's own served
|
||||
/// `props.action.answerToken`, from the actually served surface document
|
||||
/// — never from `PlayTurnView`/`OpenQuestion` directly, which a test
|
||||
/// could read but a browser never does.
|
||||
async fn play_button_token(state: &AppState, cookie: &str) -> Option<String> {
|
||||
let surface = get(state, cookie, "/api/eve/surfaces/ghostlight.play").await;
|
||||
let root = surface.get("surface")?.get("root")?;
|
||||
let button = find_surface_node(root, "world.play")?;
|
||||
button
|
||||
.get("props")?
|
||||
.get("action")?
|
||||
.get("answerToken")?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Drives `play_world_with_a_scripted_question` through `world.play` and
|
||||
/// waits for the scripted `ask_player` call to open the turn's own
|
||||
/// question, returning the fixture, table, and served version so each
|
||||
/// caller only writes its own answer's own assertions.
|
||||
async fn played_and_awaiting_question() -> (Fixture, Arc<PlayTable>, u64) {
|
||||
let (fixture, served_version) = play_world_with_a_scripted_question().await;
|
||||
let table = fixture.state.play.clone().unwrap();
|
||||
|
||||
|
|
@ -3364,23 +3633,38 @@ mod tests {
|
|||
.await;
|
||||
assert_eq!(played["state"], "accepted");
|
||||
|
||||
let asked = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await {
|
||||
if view.state == PlayTurnState::AwaitingPlayer {
|
||||
return view;
|
||||
}
|
||||
if let Some(view) = table.current_turn_view().await
|
||||
&& view.state == PlayTurnState::AwaitingPlayer
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the scripted ask_player call must open a question");
|
||||
let question_version = asked.question.as_ref().unwrap().surface_version;
|
||||
assert_eq!(
|
||||
question_version, served_version,
|
||||
"asking a question is conversational and must not move surface_version"
|
||||
);
|
||||
(fixture, table, served_version)
|
||||
}
|
||||
|
||||
/// PA.f170's own end-to-end proof: a question asked, the card served,
|
||||
/// its own answer token read out of the served surface document — never
|
||||
/// out of `PlayTurnView` directly, which a real client never sees — the
|
||||
/// answer accepted, and the turn closes with the scripted narration.
|
||||
///
|
||||
/// Mutation: change `payload.answer_token.as_deref() != Some(question.token.as_str())`
|
||||
/// to always pass (or always fail) at `runtime.rs`'s `world.play` arm —
|
||||
/// this test's own answer, echoing the exact token the surface served,
|
||||
/// would then be wrongly refused (or a broken-but-lenient comparison
|
||||
/// would let a later mismatch test wrongly pass).
|
||||
#[tokio::test]
|
||||
async fn an_answer_naming_the_open_questions_own_token_read_from_the_served_surface_is_accepted() {
|
||||
let (fixture, table, served_version) = played_and_awaiting_question().await;
|
||||
|
||||
let token = play_button_token(&fixture.state, &fixture.cookie)
|
||||
.await
|
||||
.expect("the served surface's own \"Play\" button must carry the open question's answer token");
|
||||
|
||||
let answered = post(
|
||||
&fixture.state,
|
||||
|
|
@ -3388,15 +3672,15 @@ mod tests {
|
|||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
question_version,
|
||||
json!({"text":"I go left."}),
|
||||
served_version,
|
||||
json!({"text":"I go left.","answerToken":token}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
answered["state"], "accepted",
|
||||
"an answer naming the exact version the question opened on must be accepted: {answered}"
|
||||
"an answer naming the open question's own token, read off the served surface, must be accepted: {answered}"
|
||||
);
|
||||
|
||||
let closed = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
|
|
@ -3414,19 +3698,113 @@ mod tests {
|
|||
assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet."));
|
||||
}
|
||||
|
||||
/// PA.f161's own refusal proof: an answer naming a version older than the
|
||||
/// one the question opened on — here, the version served just before
|
||||
/// `world.play` was even called to open it — is refused as stale, and the
|
||||
/// turn stays open rather than being resumed by an answer the player
|
||||
/// could not have actually seen the question at.
|
||||
/// PA.f170's own refusal proof: an answer that carries no token at all
|
||||
/// while a question is open is refused as stale, and the turn stays open
|
||||
/// rather than being resumed by an answer that could not have named the
|
||||
/// question the player was actually shown.
|
||||
///
|
||||
/// Mutation: change `source_version < question.surface_version` to
|
||||
/// `source_version < question.surface_version.saturating_sub(1)` (or any
|
||||
/// weakening of the comparison) — this test's stale answer, one version
|
||||
/// behind, would then be wrongly accepted.
|
||||
/// Mutation: change `payload.answer_token.as_deref() != Some(question.token.as_str())`
|
||||
/// to treat a missing token (`None`) as a match — this test's answer,
|
||||
/// carrying no `answerToken` field at all, would then be wrongly
|
||||
/// accepted.
|
||||
#[tokio::test]
|
||||
async fn an_answer_naming_a_version_older_than_the_question_is_refused_as_stale() {
|
||||
let (fixture, served_version) = play_world_with_a_scripted_question().await;
|
||||
async fn an_answer_missing_the_open_questions_own_token_is_refused_as_stale() {
|
||||
let (fixture, table, served_version) = played_and_awaiting_question().await;
|
||||
|
||||
let stale_answered = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"I go left."}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stale_answered["state"], "denied");
|
||||
assert!(
|
||||
stale_answered["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("stale"),
|
||||
"an answer with no token at all must be refused as stale: {stale_answered}"
|
||||
);
|
||||
|
||||
let still_open = table.current_turn_view().await.unwrap();
|
||||
assert_eq!(
|
||||
still_open.state,
|
||||
PlayTurnState::AwaitingPlayer,
|
||||
"a stale answer must leave the question open, not resume the turn"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f170: an answer naming a token that does not match the currently
|
||||
/// open question's own — forged, mistyped, or simply wrong — is refused
|
||||
/// as stale exactly like a missing one, never applied to the open
|
||||
/// question.
|
||||
///
|
||||
/// Mutation: compare only a prefix or a hash of the token instead of the
|
||||
/// exact string — this test's near-miss token (the real one with its
|
||||
/// last character flipped) would then be wrongly accepted for at least
|
||||
/// some mutations of that weakened comparison.
|
||||
#[tokio::test]
|
||||
async fn an_answer_naming_a_mismatched_token_is_refused_as_stale() {
|
||||
let (fixture, table, served_version) = played_and_awaiting_question().await;
|
||||
|
||||
let real_token = play_button_token(&fixture.state, &fixture.cookie)
|
||||
.await
|
||||
.expect("the served surface must carry the open question's own answer token");
|
||||
let mut forged = real_token.clone();
|
||||
forged.push('x');
|
||||
|
||||
let stale_answered = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"I go left.","answerToken":forged}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stale_answered["state"], "denied");
|
||||
assert!(
|
||||
stale_answered["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("stale"),
|
||||
"a mismatched token must be refused as stale: {stale_answered}"
|
||||
);
|
||||
|
||||
let still_open = table.current_turn_view().await.unwrap();
|
||||
assert_eq!(
|
||||
still_open.state,
|
||||
PlayTurnState::AwaitingPlayer,
|
||||
"a stale answer must leave the question open, not resume the turn"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f170's own core proof — the defect Soul demonstrated (an answer
|
||||
/// composed for Q1 lands on Q2, the player never saw): two questions open
|
||||
/// in the same turn, an answer carries Q1's own token, and by the time it
|
||||
/// is submitted the turn has moved on to Q2. `question_surface_version`
|
||||
/// could not refuse this: asking is conversational and never moves the
|
||||
/// world's own revision, so Q1 and Q2 would have carried the same
|
||||
/// version, and a version-only comparison could not tell them apart. The
|
||||
/// per-question token can, and must.
|
||||
///
|
||||
/// Mutation: have `runtime.rs`'s `world.play` arm resolve `answers` from
|
||||
/// `payload.text` alone, ignoring `answer_token` entirely (reverting to
|
||||
/// "any request while a question is open answers whatever is open now")
|
||||
/// — this test's Q1-token answer would then be wrongly accepted against
|
||||
/// Q2.
|
||||
#[tokio::test]
|
||||
async fn soul_an_answer_composed_for_q1_lands_on_q2_the_player_never_saw() {
|
||||
let (fixture, served_version) = play_world_with_two_scripted_questions().await;
|
||||
let table = fixture.state.play.clone().unwrap();
|
||||
|
||||
let played = post(
|
||||
|
|
@ -3443,47 +3821,175 @@ mod tests {
|
|||
.await;
|
||||
assert_eq!(played["state"], "accepted");
|
||||
|
||||
let asked = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await {
|
||||
if view.state == PlayTurnState::AwaitingPlayer {
|
||||
return view;
|
||||
}
|
||||
if let Some(view) = table.current_turn_view().await
|
||||
&& view.question.as_ref().is_some_and(|question| question.text == "Which way?")
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the scripted ask_player call must open a question");
|
||||
let question_version = asked.question.as_ref().unwrap().surface_version;
|
||||
assert!(question_version >= 1, "the version arithmetic below needs headroom");
|
||||
.expect("the first scripted ask_player call must open Q1");
|
||||
|
||||
let stale_answered = post(
|
||||
// The player reads Q1's own token off the served surface — exactly
|
||||
// what a real client's "Play" button would carry — but does not
|
||||
// answer it before the table moves on to Q2.
|
||||
let q1_token = play_button_token(&fixture.state, &fixture.cookie)
|
||||
.await
|
||||
.expect("the served surface must carry Q1's own answer token");
|
||||
|
||||
let answered_q1 = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
question_version - 1,
|
||||
json!({"text":"I go left."}),
|
||||
served_version,
|
||||
json!({"text":"left","answerToken":q1_token}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stale_answered["state"], "denied");
|
||||
assert!(
|
||||
stale_answered["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("stale"),
|
||||
"a version older than the question's own must be refused as stale: {stale_answered}"
|
||||
assert_eq!(
|
||||
answered_q1["state"], "accepted",
|
||||
"Q1's own token must still answer Q1: {answered_q1}"
|
||||
);
|
||||
|
||||
let still_open = table.current_turn_view().await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await
|
||||
&& view.question.as_ref().is_some_and(|question| question.text == "Are you sure?")
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("answering Q1 must open Q2");
|
||||
|
||||
// A late request still carrying Q1's own token — the exact shape a
|
||||
// slow retry or a second tab would produce — must never be applied
|
||||
// to Q2, which the player has not been shown yet.
|
||||
let stale_on_q2 = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"yes","answerToken":q1_token}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
still_open.state,
|
||||
PlayTurnState::AwaitingPlayer,
|
||||
"a stale answer must leave the question open, not resume the turn"
|
||||
stale_on_q2["state"], "denied",
|
||||
"an answer composed for Q1 must never land on Q2: {stale_on_q2}"
|
||||
);
|
||||
assert!(
|
||||
stale_on_q2["message"].as_str().unwrap_or_default().contains("stale"),
|
||||
"the refusal must name it stale: {stale_on_q2}"
|
||||
);
|
||||
|
||||
let still_on_q2 = table.current_turn_view().await.unwrap();
|
||||
assert_eq!(
|
||||
still_on_q2.question.as_ref().map(|question| question.text.as_str()),
|
||||
Some("Are you sure?"),
|
||||
"the wrongly-refused answer must leave Q2 open, not resolve it"
|
||||
);
|
||||
}
|
||||
|
||||
/// PA.f170: a token is single-use across distinct questions — reusing
|
||||
/// the token a *closed* turn's own answered question carried, replayed
|
||||
/// against a fresh question with a fresh token, is refused exactly like
|
||||
/// any other mismatch (the two-questions probe above already proves
|
||||
/// reuse within one still-open turn is refused; this proves reuse is
|
||||
/// never grandfathered in just because the token was once genuine).
|
||||
#[tokio::test]
|
||||
async fn a_token_from_an_already_answered_question_is_refused_against_the_next_one() {
|
||||
let (fixture, served_version) = play_world_with_two_scripted_questions().await;
|
||||
let table = fixture.state.play.clone().unwrap();
|
||||
|
||||
let played = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"I stand at a crossroads."}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(played["state"], "accepted");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await
|
||||
&& view.question.as_ref().is_some_and(|question| question.text == "Which way?")
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the first scripted ask_player call must open Q1");
|
||||
let q1_token = play_button_token(&fixture.state, &fixture.cookie)
|
||||
.await
|
||||
.expect("the served surface must carry Q1's own answer token");
|
||||
|
||||
let answered_q1 = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"left","answerToken":q1_token.clone()}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(answered_q1["state"], "accepted");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(view) = table.current_turn_view().await
|
||||
&& view.question.as_ref().is_some_and(|question| question.text == "Are you sure?")
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("answering Q1 must open Q2");
|
||||
let q2_token = play_button_token(&fixture.state, &fixture.cookie)
|
||||
.await
|
||||
.expect("the served surface must carry Q2's own answer token");
|
||||
assert_ne!(q1_token, q2_token, "Q1's own spent token must never be reissued for Q2");
|
||||
|
||||
let replayed_q1_token = post(
|
||||
&fixture.state,
|
||||
&fixture.cookie,
|
||||
invocation(
|
||||
"world.play",
|
||||
"ghostlight.world_play.v0",
|
||||
served_version,
|
||||
json!({"text":"yes","answerToken":q1_token}),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
replayed_q1_token["state"], "denied",
|
||||
"Q1's own already-spent token must never answer Q2: {replayed_q1_token}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue