Practice Round Simulator
Lets a debater configure and track a simulated tournament round β format,
side, AI judge paradigm, and AI opponent persona β the "(b) a
round-simulator UI that reads/writes through the persistence store"
follow-up named under the "π§ͺ Practice Round Simulator" bullet in
TODO.md's Research Crowdsourcing Organizer Features list.
- Route:
/practice-round - Nav: the Tools page's Prep & Practice group; the Reason Editor's
Workspace menu (
t simulatorin Ctrl/Cmd-Shift-Space's command palette) - Package:
debate-round
What it shows
A form to pick a round ID, debate-timer format, side, an AI judge
paradigm (one of the six built-in paradigms from
judge-paradigms.ts, or a custom paradigm built from a real judge's own
publicly stated preferences via buildCustomJudgeParadigm), an
optional AI opponent persona β one of the four built-in personas from
opponent-personas.ts, a freshly-typed custom persona, or one picked from
"My persona library"/"Shared by your team" (the "π€ AI Practice Opponent"
idea's own custom-persona library) β and a difficulty for that opponent (one of the
four opponentDifficulties levels β Beginner/Intermediate/Advanced/Elite β
defaulting to Intermediate). Saving composes these into a
PracticeRoundSetup via the already-existing buildPracticeRoundSetup and
stores it through state/practiceRounds.ts.
Below the form, every persisted round renders as its own card (sorted by
roundId): the judge-paradigm, opponent-persona, and (when a persona is
set) opponent-difficulty badges, how many of the round's speeches have been
submitted (looked up through the existing "Online Debate Versus AI"
aiVersusRounds.ts store, with a link to /versus-ai to actually submit
them), a "Replay round" step-through of the round's speeches once at least
one has been submitted (see "Round replay/playback view" below), a
"Generate AI opponent speech" action once it's the AI's turn (showing the
same persona/difficulty badges above the button when a persona is set), the
rendered setup sections (speech order, judge paradigm, AI opponent), a
"Generate post-round feedback for current round" form, post-round feedback
once one has been generated, and a "Get AI judge decision" action β with a
"Clear" action per round.
Once a round has been started at /versus-ai (so an aiVersusRounds.ts
record exists for the same roundId) and it's the AI's turn, "Generate AI
opponent speech" builds the request via the existing buildAiResponseRequest
and calls requestAiVersusSpeech β or, when the round's own saved
setup.opponentPersona is set, the persona- and difficulty-conditioned
requestAiVersusSpeechWithPersona (passing the round's own saved
setup.opponentDifficulty) β saving the result back through
aiVersusRounds.ts.
"Generate post-round feedback for current round" reads the round
workspace's currently selected flow (the same state/store.ts useFlowStore
mechanism CoachingSessionsPanel's "Generate coaching session for current
round" action uses) and, given a side, calls
buildAndSavePracticeRoundFeedback to derive that round's
PracticeRoundFeedback under its own already-saved judge paradigm and save
it onto the round's record. The button is only enabled while the
workspace's selected flow's id matches the card's roundId β feedback for
a round can only be generated once a round's own setup has been saved here
first.
"Get AI judge decision" resolves the round's own saved setup.judgeParadigm
against a saved flow summary (Speech Transcript Summaries, same roundId)
via round/practice-round-judge-decision-wiring.ts, calls the existing
requestJudgeDecision, and saves the verdict onto the round's own record.
Data flow
round/practice-round-simulator.ts
β buildPracticeRoundSetup({ styleKey, userSide, judgeParadigm, opponentPersona, opponentDifficulty })
β composes idea #3's buildAiVersusSpeechOrder, debate-speech-writer's
judge-paradigms.ts / opponent-personas.ts prompt builders
β buildPracticeRoundFeedback(flow, sideKey, judgeParadigm)
β composes the round's judge paradigm with the existing AI Coach Mode
buildCoachingSession
state/practiceRounds.ts (localStorage: practiceRounds)
β buildPracticeRoundsPanelView() β sorts every persisted
PracticeRoundRecord by roundId
β getPracticeRoundSubmittedSpeeches(roundId) β reads through the
existing aiVersusRounds.ts store
β buildAndSavePracticeRoundFeedback(flow, roundId, sideKey)
β derives + saves a round's feedback
from an already-flowed Flow, under
the round's own saved judge paradigm
β panels/PracticeRoundSimulatorPanel.tsx β renders the setup form and
every persisted round
β apps/debate-ai.com/app/practice-round/page.tsx β mounts the panel as a route
round/practice-round-judge-decision-wiring.ts
β buildPracticeRoundJudgeDecisionInput(roundId, judgeParadigm, sideNames)
β resolves a saved flow summary (state/flowSummaries.ts) into a
JudgeDecisionAiInput for the round's own judge paradigm
β round/judge-decision-client.ts's requestJudgeDecision(...)
β savePracticeRound({ ...record, judgeDecision })
Saving a round's setup:
panels/PracticeRoundSimulatorPanel.tsx
β buildPracticeRoundSetup(...)
β savePracticeRound({ roundId, setup, feedback: <preserved if already set> })
β panel re-reads buildPracticeRoundsPanelView() to refresh
Clearing a round:
panels/PracticeRoundSimulatorPanel.tsx
β deletePracticeRound(roundId)
β panel re-reads buildPracticeRoundsPanelView() to refreshEvery setup-composition, judge-paradigm, and opponent-persona rule already
existed and was Vitest-covered; this feature closes follow-up (b) on the
"π§ͺ Practice Round Simulator" bullet, adding one small helper to
state/practiceRounds.ts β buildPracticeRoundsPanelView, which sorts
listPracticeRounds's output for a stable panel display order (mirroring
aiVersusRounds.ts's buildAiVersusRoundsPanelView) β rather than
introducing new setup or persistence logic. Vitest-covered in
packages/debate-round/test/practiceRounds.test.ts.
A later slice wired a "Generate AI opponent speech" action (reusing the
existing AI-versus speech-generation calls against the round's own
aiVersusRounds.ts state and saved persona) and a "Get AI judge decision"
action (via round/practice-round-judge-decision-wiring.ts, composing the
round's own saved judge paradigm with a saved flow summary) into the panel,
closing follow-up (a)'s AI-speech and AI-judge-decision halves.
A further slice added state/practiceRounds.ts's
buildAndSavePracticeRoundFeedback and the panel's "Generate post-round
feedback for current round" form, closing the "feedback generation isn't
wired to a live round flow" gap this doc previously called out β until this
slice, submitting speeches (at /versus-ai) progressed the round but
post-round feedback was never actually computed anywhere in the app.
Vitest-covered in packages/debate-round/test/practiceRounds.test.ts's
buildAndSavePracticeRoundFeedback suite.
A further slice added opponentDifficulty β the "π€ AI Practice Opponent"
idea's "extend the Practice Round Simulator's own separate persona setup to
carry a difficulty too" Next item. PracticeRoundSetupInput/
PracticeRoundSetup gained an opponentDifficulty field (an
OpponentDifficulty, defaulting to DEFAULT_OPPONENT_DIFFICULTY) that
buildPracticeRoundSetup layers onto the persona's own prompt section via
buildOpponentPersonaPrompt's existing difficulty parameter β the same
mechanism OpponentPersonaPickerPanel/AiVersusRoundPanel already use.
The panel's form gained a second "Difficulty" radio group next to AI
opponent persona, saving the chosen level alongside the persona and showing
it as a second badge per round and on the "Generate AI opponent speech"
prompt; "Generate AI opponent speech" now passes the round's own saved
setup.opponentDifficulty through to requestAiVersusSpeechWithPersona.
An older persisted round with no saved opponentDifficulty (from before
this slice) resolves to DEFAULT_OPPONENT_DIFFICULTY wherever it's read,
rather than being backfilled on save. Vitest-covered:
packages/debate-round/test/practice-round-simulator.test.ts's
buildPracticeRoundSetup suite (the default vs. explicit difficulty, and
its layering into the "AI opponent" section body).
A further slice added the "comparison across a debater's past attempts"
follow-up: a "Compare your past attempts" section renders once at least one
persisted round carries a createdAt (stamped by savePracticeRound on a
round's first save, mirroring wordCountRounds.ts's createdAt convention β
not backfilled onto a round saved before this field existed, and preserved
rather than refreshed on a later update to the same roundId). The new
state/practiceRounds.ts#buildPracticeRoundAttemptsComparison builds a
chronological win/loss trend across every such round: each round's outcome
("won"/"lost"/"pending") is derived by comparing its saved
judgeDecision.winner against the side the user actually argued β read off
setup.speechOrder (PracticeRoundSetup doesn't store userSide
directly) β staying "pending" until a judge decision has been requested for
that round. Each attempt also carries its judge paradigm, opponent (or "No AI
opponent"), and its post-round feedback's coaching-prompt count once
generated. The section shows a summary line (attempts logged, win/loss/
pending counts, win rate among decided attempts) above one row per attempt
with a Won/Lost/Pending badge, plus a "Download comparison" action exporting
the same data as a plain-text file via buildPracticeRoundAttemptsComparisonText
(mirroring CoachingSessionsPanel's anchor+Blob download pattern). No new
feedback or judging logic is introduced β this reuses each round's
already-persisted feedback/judgeDecision directly. Vitest-covered:
packages/debate-round/test/practiceRounds.test.ts's
buildPracticeRoundAttemptsComparison/buildPracticeRoundAttemptsComparisonText
suites (createdAt stamping/preservation, won/lost/pending derivation for both
sides, chronological sorting, win/loss/pending tallying and win-rate
calculation, feedback-issue-count carry-through, and the rendered text's
summary and per-attempt lines).
Scoring rubric alongside the AI judge decision
Closes this bullet's "a scoring rubric shown alongside the AI judge decision"
Next item. Once a round has a judgeDecision, a "Scoring rubric β <paradigm name>" card renders next to it, listing that round's own judge paradigm's
votingPriorities each with a β
(addressed) or β¬ (not addressed) mark β
so a debater can see which of the paradigm's own priorities the decision
actually engaged with, not just the winner and a prose rationale. The card's
heading also counts how many of the priorities came out addressed ("N of M
priorities addressed"), and an addressed row shows the keyVotingIssues
entry that matched it underneath the criterion.
debate-round's new round/judge-decision-ai.ts#buildJudgeDecisionRubric
builds the checklist: for each voting-priority string, it extracts that
criterion's significant words (4+ letters, common stopwords like "the"/"and"
filtered out) and marks the row addressed when one of those words appears in
any of the decision's keyVotingIssues (recording which issue matched) or
in its rationale (no specific issue to point at, but still addressed).
This is a heuristic keyword match, not a second AI call β it can miss a
criterion addressed in different words, or loosely match on a common word,
but is good enough to flag when a paradigm's own priority (e.g. "framework")
never comes up in the decision at all. A paradigm with no fixed
votingPriorities (the custom-judge paradigm) renders a note that there's
nothing to check instead of an empty list.
Vitest-covered in packages/debate-round/test/judge-decision-ai.test.ts's
buildJudgeDecisionRubric suite (per-criterion ordering, matching via a
keyVotingIssues entry with the matched issue recorded, matching via the
rationale alone with no issue recorded, a criterion neither mentions, and
the empty-rubric case for a paradigm with no voting priorities).
Custom persona library
Closes the "π€ AI Practice Opponent" idea's "unifying the Practice Round
Simulator's own separate persona setup with [the custom-persona] library"
Next item. Before this, the "AI opponent persona" radio group here could
only pick one of the four built-in personas β no custom-persona authoring,
and no way to reuse an entry already saved to (or shared through)
OpponentPersonaPickerPanel's "My persona library". The radio group now
also has a "Custom opponent persona" option, revealing a name/style-notes
form (mirroring OpponentPersonaPickerPanel's own) with "Save to my
persona library"/"Share with my team" checkboxes; below it, a "My persona
library" list (account-synced when signed in) and a read-only "Shared by
your team" list each offer a "Use for this round"/"Use this persona" action
that prefills the custom fields, the same useCustomOpponentPersonaLibrary
hook OpponentPersonaPickerPanel already uses.
Saving resolves the form's choice β none, a built-in id, or a custom
name+notes pair β via debate-round's new
round/practice-round-simulator.ts#resolvePracticeRoundOpponentPersonaChoice
into the opponentPersona input buildPracticeRoundSetup already accepted
(it could already take a pre-built OpponentPersona object, not just a
built-in id, so no change was needed there or to PracticeRoundSetup/its
persistence). A round's persona is still saved directly onto its own
PracticeRoundSetup rather than through
state/opponentPersonaSelections.ts β that split is intentional, not a
gap: a practice round is keyed by round id, not the session id that store
uses, and both already resolve a custom persona identically via
buildCustomOpponentPersona.
Vitest-covered: packages/debate-round/test/practice-round-simulator.test.ts's
new resolvePracticeRoundOpponentPersonaChoice suite (resolving "none" to
undefined, a built-in choice to its id unresolved, a custom choice into a
usable OpponentPersona β round-tripped through buildPracticeRoundSetup β
and throwing for an empty name or empty notes, mirroring
buildCustomOpponentPersona's own validation).
Round replay/playback view
Closes the "π§ͺ Practice Round Simulator" bullet's last remaining Next item:
a round replay/playback view. Once at least one speech has been submitted
for a round (through /versus-ai), its card gains a "Replay round" section
showing one speech slot at a time β its position ("N / M"), name, a
You/AI badge, and the delivered text (or "Not yet delivered." for a slot the
round hasn't reached yet) β with Prev/Next buttons to step through the whole
sequence, disabled at the first/last step.
debate-round's new round/practice-round-simulator.ts#buildPracticeRoundReplaySteps
zips the round's own setup.speechOrder with the submitted speeches already
looked up via getPracticeRoundSubmittedSpeeches (the same "Online Debate
Versus AI" aiVersusRounds.ts store the speech-progress line already reads)
positionally β submittedSpeeches[i] is always the speech delivered for
speechOrder[i], since a round's speeches are only ever appended in turn
order β into one step per slot, delivered: false/text: null for any slot
beyond how far the round has progressed. No new persistence: the step index
is local component state (PracticeRoundSimulatorPanel's
replayStepByRound), clamped to the current step count so it never points
past the end if a round somehow has fewer steps on a later render.
Vitest-covered: packages/debate-round/test/practice-round-simulator.test.ts's
buildPracticeRoundReplaySteps suite (every step undelivered for an
unstarted round, each slot's index/name/speaker/secondary/time carried
through unchanged, a delivered prefix matching submittedSpeeches
positionally with the remainder undelivered, every step delivered once the
round is complete, and the empty-order case).
Cross-tab live update
Closes the "every other localStorage-backed panel in this repo still has no
cross-tab live-update mechanism" Known gap noted in
shared-flow-sync.md, for
PracticeRoundSimulatorPanel.
The browser's storage event never fires in the tab that made the write,
only in other same-origin tabs β before this, saving a round's setup,
clearing a round, generating an AI opponent speech, or getting an AI judge
decision in one tab left every other open /practice-round tab showing a
stale round list until a manual reload.
PracticeRoundSimulatorPanel.tsx now subscribes to window's storage
event and calls its existing refresh() closure whenever
state/live-update.ts's isPracticeRoundSimulatorPanelLiveUpdateStorageEvent
matches β covering both stores the panel reads directly:
practiceRounds (the saved-round-setup list itself) and aiVersusRounds
(read via getAiVersusRound/getPracticeRoundSubmittedSpeeches for each
round's submitted-speech progress and "Generate AI opponent speech"
availability). A teammate saving or clearing a round's setup, submitting or
generating a speech, or getting an AI judge decision in one tab now shows up
in every other open tab without a manual reload. The account-synced custom
opponent persona library ("My persona library"/"Shared by your team", via
useCustomOpponentPersonaLibrary) is deliberately excluded β it manages its
own refresh through that hook rather than a raw localStorage read,
matching OpponentPersonaPickerPanel's own exclusion of the same hook. The
in-progress round-setup form draft, feedback side-key fields, and replay-step
selections are left untouched, matching every other closed panel's "refresh
the derived view, not the draft" convention.
state/live-update.ts (debate-practice-drills) β PRACTICE_ROUND_SIMULATOR_PANEL_LIVE_UPDATE_STORAGE_KEYS, isPracticeRoundSimulatorPanelLiveUpdateStorageEvent
panels/PracticeRoundSimulatorPanel.tsx β storage-event subscription, refreshes roundsVitest-covered:
packages/debate-practice-drills/test/live-update.test.ts (both backing-
store keys, the null-key clear-all case, and unrelated/substring-matching
keys staying ignored).
Known gaps
- Saved rounds sync to a signed-in user's account as the
practiceRoundscollection (see Tool Data Sync); a signed-out browser still keeps them inlocalStorageonly. No further known gaps remain for this idea beyond the cross-tab live update above, and no further follow-up is currently tracked for the "π§ͺ Practice Round Simulator" bullet in TODO.md's Research Crowdsourcing Organizer Features list.
