Practice vs AI

The /versus-ai round is a Node/TypeScript port of the Go arguehub vs-bot server (packages/debate-round-practice-ai, published as debate-practice-vs-ai), kept as reference sources in the package. Persona data, prompt construction and AI judging come straight from that port. This note covers what the app layer supplies: persistence.

The DebateStore seam

debate-practice-vs-ai's ./backend export defines a DebateStore interface and ships createInMemoryDebateStore for tests. Everything but createDebate/getLatestDebate/setOutcome is optional, so a host can start with bare persistence and add transcripts or scoring later — exactly what happened here. apps/debate-ai.com/lib/practice-vs-ai/store.ts supplies the Drizzle/D1-backed implementation the /api/vsbot/* routes use (apps/debate-ai.com/lib/practice-vs-ai/backend.ts wires the two together).

Rounds and transcripts

Each round is one row in practice_vs_ai_debates, keyed by user_id. The full DebateVsBotRecord (history, phase timings, stance, bot level) lives in the row's data JSON blob — the same pattern saved_flows/saved_rounds use — with owner, bot, topic, outcome and result lifted into columns so the round list can be queried without parsing the blob. saveTranscript stays unset: createDebate/appendMessage/setOutcome already keep that blob current, so a second transcript table would just be a redundant write.

Gamification

packages/debate-round-practice-ai/src/backend/gamification.ts ports the Go controller's updateGamificationAfterBotDebate/ checkAndAwardAutomaticBadges as pure functions: computeGamificationAward takes a { score, badges, currentStreak } profile and a result, and returns the points, badges and new score to apply — the same point values (50/25/10 for a win/draw/loss) and badge thresholds (FirstWin, Novice at 10, Streak5 at a 5-day streak, FactMaster at 500) as the Go switch. handlers.ts's recordCompletedRound, which every /judge and /concede request runs after storing the round's outcome, does not call computeGamificationAward itself — it hands the round's { debateType, topic, result } straight to store.applyGamificationAward, which is the only thing that reads the profile and computes the award, and returns it in the response body so the client can show it. (Earlier, the handler read the profile and computed the award itself, then handed a { points, badges, newScore } snapshot to the store to persist — two rounds finishing close together, separated by the AI judging call, could each compute their award from the same stale starting score, and the second store write would silently overwrite the first round's points. There's no longer a parameter for a caller to pass a precomputed award through at all.)

The app's store persists the result on the signed-in user's user_settings row rather than a separate table, mirroring the account-preferences pattern app/api/settings/route.ts already uses:

  • practiceVsAiScore (integer) — the running total.
  • practiceVsAiBadges (JSON array of badge ids) — merged with, never replacing, whatever the row already holds, so a repeat badge check never drops one earned in an earlier round.
  • practiceVsAiLastPlayedDayKey (the UTC YYYY-MM-DD of the most recent scored round) and practiceVsAiCurrentStreak (integer) — the dated activity log Streak5 needed. debate-practice-vs-ai's advanceDailyStreak(lastPlayedDayKey, today, previousStreak) is the pure rule: a second round the same day leaves it unchanged, the calendar day right after extends it by one, and anything else (first round ever, or a missed day) restarts it at 1. The Go original read user.CurrentStreak in its own badge check but never incremented it anywhere in debatevsbot_controller.go, so Streak5 was unreachable there too — this isn't a regression the port introduced.

getGamificationProfile reads all four columns (defaulting to 0/[]/no streak when the user has no row yet) and reports the streak via currentDisplayStreak, which reads as the stored value while the last round was today or yesterday and as 0 once a day has been missed — without needing a new round played to reset the stored counter first. It is otherwise unused by the write path now (kept as a capability probe, and for any host that wants to display a profile): applyGamificationAward re-reads all four columns itself immediately before writing, advances the streak with advanceDailyStreak, and runs computeGamificationAward against that fresh profile, then does a read-then-merge upsert (insert … onConflictDoUpdate) — the score, badge list and streak are always computed from the row's current value, the same reason favoriteTools' star/unstar op reads the row's current value first.

The scorecard (packages/debate-round-practice-ai/src/ui/JudgmentPopup.tsx) renders the award's points and any newly-awarded badges in a "Round Rewards" section when the judge response includes one; the concede flow's toast adds a (+N points) note instead, since conceding doesn't show the scorecard.

Browsing debate history

Every round was already fully persisted (practice_vs_ai_debates.data), but nothing read the list back — a finished round was effectively unreachable again once the user left the scorecard. DebateStore gained an optional listDebates(email) (packages/debate-round-practice-ai/src/backend/store.ts), implemented by both createInMemoryDebateStore (filters its in-memory map by email) and the app's D1 store (apps/debate-ai.com/lib/practice-vs-ai/store.ts, which ignores the email argument and reuses the constructor's userId closure, same as getLatestDebate) — SELECT ... WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT 50, reusing the existing rowToRecord helper so the response carries the full transcript, not just the list columns.

createPracticeVsAiBackend exposes this as a fifth handler, listDebates, which returns an empty list rather than an error when the store doesn't implement the hook — the same "optional hook, harmless absence" treatment recordCompletedRound gives the gamification hooks. GET /api/vsbot/history (apps/debate-ai.com/app/api/vsbot/history/route.ts) is a thin wrapper: resolve the session, build the backend with model: null (listing never calls the model), call listDebates. Unlike the other four routes it takes no body, so it doesn't go through withVsBotBackend (which always parses one).

The client's listDebateHistory() (packages/debate-round-practice-ai/src/client/index.ts) GETs that route with the session cookie, same as the other four calls. The UI is a new screen, DebateHistory (packages/debate-round-practice-ai/src/ui/DebateHistory.tsx): a list of past rounds (topic, opponent, a win/loss/draw/conceded/in-progress badge derived client-side via resolveResultStatus, and the date), each expandable in place to its transcript. BotSelection gained an optional onViewHistory prop that renders a "View Debate History" button when supplied; DebatePracticeVsAi wires it to a showHistory flag that swaps the picker for DebateHistory while no round is active.

Resuming a round after a reload

DebateRoom already kept its own transcript in a debate_${userId}_${topic}_${debateId} localStorage key, restored whenever it mounts with matching props — but DebatePracticeVsAi (the screen the route renders) only ever learned which round to mount from a plain useState, which a hard reload discards, so the round always fell back to the picker even though the transcript was still sitting in localStorage.

packages/debate-round-practice-ai/src/ui/active-round.ts closes that gap: writeActiveRound(userId, started) mirrors the round's setup (debateId, bot, topic, stance, phase timings — everything DebateRoom needs as props) into a practiceVsAiActiveRound_${userId} key when a round starts; DebatePracticeVsAi reads it back with readActiveRound(userId) as its debate state's initializer, so a reload mounts straight into DebateRoom with the same props, which then resumes its own transcript as it always did. clearActiveRound(userId) runs on onExit (judged or conceded), so a finished round doesn't keep resuming. Signed-out users share one _guest bucket, the same fallback DebateRoom's own resume key already uses.

Known gaps

See the feature doc's Known gaps — the streak gap it used to describe is fixed; nothing else here is currently open.

Tests

packages/debate-round-practice-ai/test/handlers.test.ts drives the five handlers against the in-memory store, including that a store without the gamification hooks omits gamification from the response entirely, and that listDebates returns an empty list for a store without that hook. apps/debate-ai.com/lib/practice-vs-ai/__tests__/store.test.ts exercises the D1-backed getGamificationProfile/applyGamificationAward against a real in-memory SQLite database (score and badge accumulation across rounds, de-duplication, per-user isolation, a case demonstrating the lost-update race is closed: a profile read before either of two sequential awards still ends with both rounds' points, not just the second, and the streak: five consecutive simulated days earns Streak5, a second round the same day doesn't advance it, a missed day restarts it at 1, and a lapsed streak reads as 0 without a write) plus listDebates (newest-first ordering, per-user isolation, empty list). packages/debate-round-practice-ai/test/gamification.test.ts covers advanceDailyStreak/currentDisplayStreak/utcDayKey directly as pure functions, including a UTC month-boundary carry. packages/debate-round-practice-ai/test/store-and-client.test.ts covers the in-memory store's gamification (same race-closed case) and listDebates, plus the client's listDebateHistory (GET request, session cookie, error surfacing). packages/debate-round-practice-ai/test/active-round.test.ts covers active-round.ts: round-trip read/write, per-user namespacing, the shared guest bucket, and tolerating a missing, corrupt, or malformed-shape record.