"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) => 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([]); // --- Config --- const setConfig = useCallback( (durationMinutes: 3 | 5 | 10 | 15) => { const presets: Record = { 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[] }; const batch = Date.now(); const mapped: DebateTopic[] = topics.map( (t: Record, i: number) => ({ id: `topic-${batch}-${i}`, ...(t as unknown as Omit), }), ); seenTopicsRef.current.push(...mapped.map((t) => t.topic)); dispatch({ type: "SET_TOPICS", topics: mapped }); broadcast("debate_topics", { topics: mapped as unknown as Record[] }); }, [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 }); 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, 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, ]); 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, 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 = { 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, }; }