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 simulator in 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 refresh

Every 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 rounds

Vitest-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 practiceRounds collection (see Tool Data Sync); a signed-out browser still keeps them in localStorage only. 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.