Triviaverse / src /hooks /useDebate.ts
Simeon Garratt
feat(spark): rewrite useDebate for conversational flow with commentary + challenge
7f5746e
Raw
History Blame Contribute Delete
12.1 kB
"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,
};
}