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 UTCYYYY-MM-DDof the most recent scored round) andpracticeVsAiCurrentStreak(integer) — the dated activity logStreak5needed.debate-practice-vs-ai'sadvanceDailyStreak(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 readuser.CurrentStreakin its own badge check but never incremented it anywhere indebatevsbot_controller.go, soStreak5was 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.
