Fix batch 8a-fix2: Dungeon play loop (PA.f83-PA.f97)

One Dungeon commit: turn identity, the answer/continue contract, fault
closing, and retry disposition all read and mutate the same PlayTurn/
PlayTurnStore state machine inside run()/execute_round(), so splitting them
across commits would leave intermediate commits that don't build toward a
coherent state (e.g. PA.f84's answers-matching depends on PA.f83's
turn_id/QuestionId identity; PA.f85's close_with_fault helper is what
PA.f94's retry-disposition and PA.f97's collision fault both close through).

PA.f83 (turn identity): turn_id is minted fresh (Uuid::new_v4) when a turn
opens; never taken from a request key. The store's PlayTurnStoreState now
also carries a KeyLedger: a closed turn's own applied_keys are archived into
it (PlayTurnStore::open_new_turn) when the next turn opens, covering the
most recent KEY_LEDGER_WINDOW (64) closed turns plus the still-open one's
own applied_keys (PlayTurnStore::key_known). A key found either way is a
no-op.

PA.f84 (answer identity): PlayTable::run now takes a PlayRequest{text,
answers: Option<QuestionId>} (PlayRequest: From<&str>/From<String> keep
almost every existing call site unchanged via `.into()`). QuestionId is
(turn_id, round, slot), derived by PlayTurn::open_question_id from the
currently open ask_player call. An AwaitingPlayer turn only accepts an
answer whose QuestionId matches exactly (PlayError::StaleAnswer otherwise).
A Running turn accepts empty text as a plain continue and refuses non-empty
text (PlayError::TurnStillRunning). `answers` given with no question open
is PlayError::NoQuestionOpen.

PA.f85/PA.f93 (close-with-fault helper): one close_with_fault(turn, detail)
closes the turn, records the fault, and persists it. Every fault path after
a turn opens now goes through it: the three duplicated inline blocks
(authoring-run Invariant, advance_time Invariant, actor-call Invariant) are
collapsed into it; a request-build failure, a snapshot failure (execute_
round's own, execute_dispatch's per-subject one), and a narrate failure
(close_turn, called from both the round-budget path and end_turn) all route
through it too, rather than leaving the turn Running behind a bare `?`.

PA.f94 (retry disposition): infer_round's retry loop now reads
InferenceFault::disposition() through the new absorb_or_close helper: only
Retryable retries, bounded by ROUND_RETRY_BUDGET with a small capped
exponential backoff (retry_delay, #[cfg(test)] zeroed — the simplest
injection seam). RecoveryRequired and IntegrityViolation close on their
first occurrence. The stale "library gap" doc and its dangling "module docs
at the bottom" reference are deleted.

PA.f86: no production change was needed — rebuild_conversation already used
turn.opening_prompt, not a fresh table_view. Added the missing regression
test and ran the described mutation against it.

PA.f87: deleted the byte-offset pin test. Replaced with two behavioural
ones: a_resumed_authoring_run_resubmits_its_recorded_shared_patch (a 2-call
run's crash-window resume, mirroring the existing single-call precedent) and
commit_authoring_run_records_the_run_even_when_its_submit_is_refused (a
first-attempt run whose kernel submission is genuinely refused still records
its own decoded patch before/regardless of that refusal). Both mutations
named in the spec (delete the record, move it into the success arm) were
run against the second test and fail as required.

PA.f88 (Dungeon side): added play_tools_is_pinned_to_its_exact_contents_
with_no_communicate, pinning PLAY_TOOLS's exact 24 entries.

PA.f90: PlayTurn::dispatched_subjects now requires a persona_turns entry for
the subject, not just a Dispatch call record — a dispatch whose Persona
faulted or found no opportunity no longer counts as dispatched.

PA.f95: the round-budget doc, applied_keys/fault's dead #[serde(default)]
(and the same reasoning applied to the new KeyLedger field), and
close_turn's/begin_turn's unused parameters are all gone.

PA.f96: replaced the old dispatch_command_id_for_handle unit test with
dispatch_order_never_changes_the_request_id_a_subject_gets: two Persona
subjects dispatched under one shared turn_id, once [Mara, Borin] and once
[Borin, Mara], comparing each subject's own inference request_id (which
carries dispatch_command_id's actual output) across both orders via the
production execute_dispatch path itself, not a recomputed hash. The
position-hashing mutation was run against this test and fails.

PA.f97: handle_for is now the subject id's first 8 hex digits. A collision
among the snapshot's own subjects is a turn fault via find_handle_collision,
wired into round_tools (its Result<_, String> already flows through
infer_round into close_with_fault). Two subjects sharing a handle cannot be
forced from real, randomly issued SubjectIds (no public constructor exists
outside ghostlight), so find_handle_collision's own detection algorithm is
tested directly against two real ids under a shared, hand-assigned handle
string; the genuine end-to-end fixture collision is reported as infeasible
in this pass's own report rather than faked.

Test counts: Dungeon bin 78 -> 98, lib 621 -> 622 (unchanged by this
commit, from the prior library batch), persona-projection 26 -> 30
(likewise). Every new/changed rule was run against its own reverting or
loosening mutation and confirmed to fail before being restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 18:15:07 +02:00
commit 74b628dfd7

File diff suppressed because it is too large Load diff