Spaces:
Running
Running
Simeon Garratt
feat(spark): rewrite useDebate for conversational flow with commentary + challenge
7f5746e | "use client"; | |
| import { useReducer, useCallback, useRef } from "react"; | |
| import { debateReducer, createInitialDebateState } from "@/lib/spark/engine"; | |
| import type { | |
| DebateConfig, | |
| DebateTopic, | |
| DebateVerdict, | |
| RoundData, | |
| RoundCommentary, | |
| ChallengeResult, | |
| } from "@/lib/spark/types"; | |
| export interface UseDebateOptions { | |
| isHost: boolean; | |
| myPlayerId: string; | |
| opponentPlayerId: string; | |
| broadcast: (event: string, payload: Record<string, unknown>) => void; | |
| } | |
| export function useDebate({ | |
| isHost, | |
| myPlayerId, | |
| opponentPlayerId, | |
| broadcast, | |
| }: UseDebateOptions) { | |
| const [state, dispatch] = useReducer( | |
| debateReducer, | |
| undefined, | |
| createInitialDebateState, | |
| ); | |
| const isPlayer1 = isHost; | |
| const player1Id = isHost ? myPlayerId : opponentPlayerId; | |
| const player2Id = isHost ? opponentPlayerId : myPlayerId; | |
| const seenTopicsRef = useRef<string[]>([]); | |
| // --- Config --- | |
| const setConfig = useCallback( | |
| (durationMinutes: 3 | 5 | 10 | 15) => { | |
| const presets: Record<number, { rounds: number; timePerTurn: number }> = { | |
| 3: { rounds: 4, timePerTurn: 45 }, | |
| 5: { rounds: 8, timePerTurn: 38 }, | |
| 10: { rounds: 12, timePerTurn: 50 }, | |
| 15: { rounds: 12, timePerTurn: 75 }, | |
| }; | |
| const { rounds, timePerTurn } = presets[durationMinutes]; | |
| const config: DebateConfig = { durationMinutes, rounds, timePerTurn }; | |
| dispatch({ type: "SET_CONFIG", config }); | |
| broadcast("debate_config", { config }); | |
| }, | |
| [broadcast], | |
| ); | |
| const receiveConfig = useCallback((config: DebateConfig) => { | |
| dispatch({ type: "SET_CONFIG", config }); | |
| }, []); | |
| // --- Topics --- | |
| const fetchTopics = useCallback( | |
| async (category?: string, region?: string) => { | |
| const res = await fetch("/api/spark/topics", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ category, region, excludeTopics: seenTopicsRef.current }), | |
| }); | |
| if (!res.ok) throw new Error(`Topic generation failed (${res.status})`); | |
| const { topics } = (await res.json()) as { topics: Record<string, unknown>[] }; | |
| const batch = Date.now(); | |
| const mapped: DebateTopic[] = topics.map( | |
| (t: Record<string, unknown>, i: number) => ({ | |
| id: `topic-${batch}-${i}`, | |
| ...(t as unknown as Omit<DebateTopic, "id">), | |
| }), | |
| ); | |
| seenTopicsRef.current.push(...mapped.map((t) => t.topic)); | |
| dispatch({ type: "SET_TOPICS", topics: mapped }); | |
| broadcast("debate_topics", { topics: mapped as unknown as Record<string, unknown>[] }); | |
| }, | |
| [broadcast], | |
| ); | |
| const receiveTopics = useCallback((topics: DebateTopic[]) => { | |
| dispatch({ type: "SET_TOPICS", topics }); | |
| }, []); | |
| const addCustomTopic = useCallback( | |
| (topic: string, question: string) => { | |
| const customTopic: DebateTopic = { | |
| id: `custom-${Date.now()}`, | |
| topic, | |
| question, | |
| category: "custom", | |
| heatLevel: 3, | |
| }; | |
| dispatch({ type: "ADD_TOPIC", topic: customTopic }); | |
| broadcast("debate_custom_topic", { topic: customTopic as unknown as Record<string, unknown> }); | |
| return customTopic; | |
| }, | |
| [broadcast], | |
| ); | |
| const receiveCustomTopic = useCallback((topic: DebateTopic) => { | |
| dispatch({ type: "ADD_TOPIC", topic }); | |
| }, []); | |
| const voteTopic = useCallback( | |
| (topicId: string) => { | |
| dispatch({ type: "VOTE_TOPIC", topicId }); | |
| broadcast("debate_vote", { topicId }); | |
| }, | |
| [broadcast], | |
| ); | |
| const receiveVote = useCallback((opponentTopicId: string) => { | |
| dispatch({ type: "OPPONENT_VOTED" }); | |
| return opponentTopicId; | |
| }, []); | |
| const selectTopic = useCallback((topic: DebateTopic) => { | |
| dispatch({ type: "SELECT_TOPIC", topic }); | |
| }, []); | |
| // --- Rounds (conversational) --- | |
| const updateArgument = useCallback((text: string) => { | |
| dispatch({ type: "UPDATE_ARGUMENT", text }); | |
| }, []); | |
| /** Submit the opening argument for this round. Both sides see it immediately. */ | |
| const submitOpening = useCallback( | |
| (text: string) => { | |
| dispatch({ type: "SET_OPENING_ARG", argument: text }); | |
| broadcast("debate_opening", { argument: text, roundNumber: state.currentRound }); | |
| }, | |
| [broadcast, state.currentRound], | |
| ); | |
| /** Receive the opponent's opening argument. */ | |
| const receiveOpening = useCallback((argument: string) => { | |
| dispatch({ type: "SET_OPENING_ARG", argument }); | |
| }, []); | |
| /** Submit the rebuttal, fact-check both arguments, assemble round data. */ | |
| const submitRebuttal = useCallback( | |
| async (rebuttalText: string) => { | |
| const openerArg = state.currentOpeningArg; | |
| const openerId = state.currentOpener === "player1" ? player1Id : player2Id; | |
| const topic = state.selectedTopic?.topic ?? ""; | |
| const question = state.selectedTopic?.question ?? ""; | |
| // Fact-check both arguments in parallel | |
| const [openerFC, rebuttalFC] = await Promise.all([ | |
| fetch("/api/spark/fact-check", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ argument: openerArg, topic, question }), | |
| }) | |
| .then((r) => r.json()) | |
| .then((d: { annotations?: unknown[] }) => d.annotations ?? []) as Promise<RoundData["openerFactCheck"]>, | |
| fetch("/api/spark/fact-check", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ argument: rebuttalText, topic, question }), | |
| }) | |
| .then((r) => r.json()) | |
| .then((d: { annotations?: unknown[] }) => d.annotations ?? []) as Promise<RoundData["rebuttalFactCheck"]>, | |
| ]); | |
| const round: RoundData = { | |
| roundNumber: state.currentRound, | |
| openerId, | |
| openerArgument: openerArg, | |
| rebuttalArgument: rebuttalText, | |
| openerFactCheck: openerFC, | |
| rebuttalFactCheck: rebuttalFC, | |
| commentary: null, | |
| }; | |
| dispatch({ type: "COMPLETE_ROUND", round }); | |
| broadcast("debate_rebuttal", { | |
| argument: rebuttalText, | |
| round: round as unknown as Record<string, unknown>, | |
| roundNumber: state.currentRound, | |
| }); | |
| return round; | |
| }, | |
| [state.currentOpeningArg, state.currentOpener, state.currentRound, state.selectedTopic, player1Id, player2Id, broadcast], | |
| ); | |
| /** Receive the opponent's rebuttal and the assembled round data. */ | |
| const receiveRebuttal = useCallback((round: RoundData) => { | |
| dispatch({ type: "COMPLETE_ROUND", round }); | |
| }, []); | |
| /** Fetch inter-round commentary from the judge. */ | |
| const fetchCommentary = useCallback( | |
| async (round: RoundData, player1Name: string, player2Name: string) => { | |
| const openerName = round.openerId === player1Id ? player1Name : player2Name; | |
| const rebutterName = round.openerId === player1Id ? player2Name : player1Name; | |
| // Build prior rounds summary | |
| const priorSummary = | |
| state.rounds.length > 1 | |
| ? state.rounds | |
| .slice(0, -1) | |
| .map((r) => { | |
| const oName = r.openerId === player1Id ? player1Name : player2Name; | |
| const rName = r.openerId === player1Id ? player2Name : player1Name; | |
| return `Round ${r.roundNumber}: ${oName} argued "${r.openerArgument.slice(0, 80)}..." ${rName} countered "${r.rebuttalArgument.slice(0, 80)}..."`; | |
| }) | |
| .join("\n") | |
| : null; | |
| const res = await fetch("/api/spark/commentary", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| topic: state.selectedTopic?.topic, | |
| question: state.selectedTopic?.question, | |
| currentRound: round.roundNumber, | |
| openerName, | |
| rebutterName, | |
| openerArgument: round.openerArgument, | |
| rebuttalArgument: round.rebuttalArgument, | |
| priorSummary, | |
| }), | |
| }); | |
| if (!res.ok) throw new Error(`Commentary API failed (${res.status})`); | |
| const commentary = (await res.json()) as RoundCommentary; | |
| // Normalize momentum to player1/player2 perspective | |
| const normalizedMomentum = | |
| round.openerId === player1Id ? -commentary.momentum : commentary.momentum; | |
| const normalized: RoundCommentary = { ...commentary, momentum: normalizedMomentum }; | |
| dispatch({ type: "SET_COMMENTARY", commentary: normalized }); | |
| return normalized; | |
| }, | |
| [state.rounds, state.selectedTopic, player1Id], | |
| ); | |
| /** Apply commentary received from the host (multiplayer guest). */ | |
| const receiveCommentary = useCallback((commentary: RoundCommentary) => { | |
| dispatch({ type: "SET_COMMENTARY", commentary }); | |
| }, []); | |
| /** Advance to the next round. */ | |
| const startNextRound = useCallback(() => { | |
| dispatch({ type: "START_ROUND" }); | |
| }, []); | |
| // --- Judging --- | |
| const requestVerdict = useCallback( | |
| async (player1Name: string, player2Name: string) => { | |
| dispatch({ type: "START_JUDGING" }); | |
| const res = await fetch("/api/spark/judge", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| topic: state.selectedTopic?.topic, | |
| question: state.selectedTopic?.question, | |
| rounds: state.rounds, | |
| player1Id, | |
| player2Id, | |
| player1Name, | |
| player2Name, | |
| }), | |
| }); | |
| if (!res.ok) throw new Error(`Judge API failed (${res.status})`); | |
| const { verdict } = (await res.json()) as { verdict: DebateVerdict }; | |
| return verdict; | |
| }, | |
| [state.selectedTopic, state.rounds, player1Id, player2Id], | |
| ); | |
| const setVerdict = useCallback( | |
| (verdict: DebateVerdict, eloChange: number) => { | |
| dispatch({ type: "SET_VERDICT", verdict, eloChange }); | |
| }, | |
| [], | |
| ); | |
| // --- Challenge --- | |
| const challengeVerdict = useCallback( | |
| async (player1Name: string, player2Name: string) => { | |
| dispatch({ type: "START_CHALLENGE" }); | |
| const res = await fetch("/api/spark/judge", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| topic: state.selectedTopic?.topic, | |
| question: state.selectedTopic?.question, | |
| rounds: state.rounds, | |
| player1Id, | |
| player2Id, | |
| player1Name, | |
| player2Name, | |
| challengeMode: true, | |
| originalVerdict: state.verdict?.analysis ?? "", | |
| }), | |
| }); | |
| if (!res.ok) throw new Error(`Challenge API failed (${res.status})`); | |
| const data = (await res.json()) as { | |
| challengeOutcome: string; | |
| challengeReasoning: string; | |
| verdict: DebateVerdict; | |
| }; | |
| const coinsMap: Record<string, number> = { upheld: 0, overturned: 50, modified: 15 }; | |
| const result: ChallengeResult = { | |
| outcome: data.challengeOutcome as ChallengeResult["outcome"], | |
| newVerdict: data.challengeOutcome === "upheld" ? null : data.verdict, | |
| reasoning: data.challengeReasoning, | |
| coinsReturned: coinsMap[data.challengeOutcome] ?? 0, | |
| }; | |
| return result; | |
| }, | |
| [state.selectedTopic, state.rounds, state.verdict, player1Id, player2Id], | |
| ); | |
| const setChallengeResult = useCallback( | |
| (result: ChallengeResult, newEloChange: number) => { | |
| dispatch({ type: "SET_CHALLENGE_RESULT", result, newEloChange }); | |
| }, | |
| [], | |
| ); | |
| const finish = useCallback(() => { | |
| dispatch({ type: "FINISH" }); | |
| }, []); | |
| const reset = useCallback(() => { | |
| dispatch({ type: "RESET" }); | |
| }, []); | |
| return { | |
| state, | |
| isPlayer1, | |
| player1Id, | |
| player2Id, | |
| setConfig, | |
| receiveConfig, | |
| fetchTopics, | |
| receiveTopics, | |
| addCustomTopic, | |
| receiveCustomTopic, | |
| voteTopic, | |
| receiveVote, | |
| selectTopic, | |
| updateArgument, | |
| submitOpening, | |
| receiveOpening, | |
| submitRebuttal, | |
| receiveRebuttal, | |
| fetchCommentary, | |
| receiveCommentary, | |
| startNextRound, | |
| requestVerdict, | |
| setVerdict, | |
| challengeVerdict, | |
| setChallengeResult, | |
| finish, | |
| reset, | |
| }; | |
| } | |