numeric options
This commit is contained in:
parent
55c4493459
commit
3be0ed058b
8 changed files with 244 additions and 47 deletions
|
|
@ -26,18 +26,31 @@ export type PartySocketOutgoing =
|
|||
| { type: "ping" }
|
||||
| { type: "member_payload"; payload: unknown };
|
||||
|
||||
export type Question = {
|
||||
type BaseQuestion = {
|
||||
text: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
points: number;
|
||||
};
|
||||
|
||||
export type Question =
|
||||
| (BaseQuestion & {
|
||||
type: "choice";
|
||||
options: string[];
|
||||
})
|
||||
| (BaseQuestion & {
|
||||
type: "numeric";
|
||||
range: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
});
|
||||
|
||||
export type QuizResponse = {
|
||||
playerId: string;
|
||||
selected: number;
|
||||
selectedValue?: number;
|
||||
correct: boolean;
|
||||
answeredAt: number;
|
||||
pointsGained: number;
|
||||
|
|
@ -59,6 +72,7 @@ export type QuizState = {
|
|||
{
|
||||
playerId: string;
|
||||
selected: number;
|
||||
selectedValue?: number;
|
||||
correct: boolean;
|
||||
pointsGained: number;
|
||||
answeredAt: number;
|
||||
|
|
|
|||
|
|
@ -11,14 +11,17 @@ export function buildAudioMetadataQuestion(
|
|||
analytics: PartyAnalytics,
|
||||
index: number,
|
||||
): Question {
|
||||
const questions: Array<Omit<Question, "startTimestamp" | "endTimestamp">> = [
|
||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
|
||||
{
|
||||
type: "choice",
|
||||
text: "Which genre appears most in the party analytics?",
|
||||
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"],
|
||||
correct: 0,
|
||||
points: 10,
|
||||
},
|
||||
{
|
||||
type: "choice",
|
||||
text: "Which artist shows up most often in the shared audio data?",
|
||||
options: [
|
||||
getTopArtistName(analytics),
|
||||
|
|
@ -30,6 +33,7 @@ export function buildAudioMetadataQuestion(
|
|||
points: 10,
|
||||
},
|
||||
{
|
||||
type: "choice",
|
||||
text: "Which track looks most shared across the party?",
|
||||
options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"],
|
||||
correct: 0,
|
||||
|
|
|
|||
47
api/src/party/numeric-question-generator.ts
Normal file
47
api/src/party/numeric-question-generator.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { db } from "../db";
|
||||
import type { Question } from "../party-types";
|
||||
import type { PartyAnalytics } from "./question-utils";
|
||||
import { buildQuestionWindow, getQuestionRange } from "./question-utils";
|
||||
|
||||
type NumericQuestion = Omit<Extract<Question, { type: "numeric" }>, "startTimestamp" | "endTimestamp">;
|
||||
|
||||
type BuildNumericQuestionInput = {
|
||||
db: typeof db;
|
||||
analytics: PartyAnalytics;
|
||||
index: number;
|
||||
};
|
||||
|
||||
async function getAlbumReleaseYear({
|
||||
db,
|
||||
analytics,
|
||||
index,
|
||||
}: BuildNumericQuestionInput): Promise<NumericQuestion> {
|
||||
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
|
||||
const track = trackName
|
||||
? await db.query.track.findFirst({
|
||||
where: {
|
||||
name: trackName,
|
||||
},
|
||||
with: {
|
||||
album: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const correct =
|
||||
track?.album?.release_date?.getFullYear() ??
|
||||
new Date().getFullYear() - 1 - index;
|
||||
const subject = track?.album?.name ?? track?.name ?? "the album";
|
||||
return {
|
||||
type: "numeric",
|
||||
text: `What number best matches ${subject}?`,
|
||||
correct,
|
||||
range: getQuestionRange(correct, 5),
|
||||
points: 10,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildNumericQuestion(
|
||||
input: BuildNumericQuestionInput,
|
||||
): Promise<Question> {
|
||||
return buildQuestionWindow(await getAlbumReleaseYear(input));
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import type { db } from "../db";
|
||||
import type { Question, QuizState } from "../party-types";
|
||||
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
||||
import { buildNumericQuestion } from "./numeric-question-generator";
|
||||
import type { PartyAnalytics } from "./question-utils";
|
||||
import { fetchPartyMembers } from "./question-utils";
|
||||
import { buildSocialQuestion } from "./social-question-generator";
|
||||
|
||||
export type PartyQuestionType = "audio-metadata" | "social";
|
||||
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
||||
|
||||
type GenerateQuestionInput = {
|
||||
db: typeof db;
|
||||
|
|
@ -23,8 +24,11 @@ export async function generatePartyQuestion({
|
|||
index,
|
||||
}: GenerateQuestionInput): Promise<Question> {
|
||||
const members = await fetchPartyMembers(dbClient, partyId);
|
||||
const type: PartyQuestionType = index % 2 === 0 ? "audio-metadata" : "social";
|
||||
const type: PartyQuestionType =
|
||||
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
|
||||
return type === "audio-metadata"
|
||||
? buildAudioMetadataQuestion(analytics, index)
|
||||
: buildSocialQuestion(quizState, analytics, members, index);
|
||||
: type === "numeric"
|
||||
? buildNumericQuestion({ db: dbClient, analytics, index })
|
||||
: buildSocialQuestion(quizState, analytics, members, index);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,29 @@ export function buildQuestionWindow<T extends object>(
|
|||
};
|
||||
}
|
||||
|
||||
export function getQuestionDistanceScore(
|
||||
selectedValue: number,
|
||||
correctValue: number,
|
||||
maxDistance: number,
|
||||
points: number,
|
||||
): number {
|
||||
if (!Number.isFinite(selectedValue)) return 0;
|
||||
if (maxDistance <= 0) return selectedValue === correctValue ? points : 0;
|
||||
const distance = Math.abs(selectedValue - correctValue);
|
||||
const ratio = Math.max(0, 1 - distance / maxDistance);
|
||||
return Math.round(points * ratio);
|
||||
}
|
||||
|
||||
export function getQuestionRange(
|
||||
correctValue: number,
|
||||
tolerance: number,
|
||||
): { min: number; max: number } {
|
||||
return {
|
||||
min: Math.floor(correctValue - tolerance),
|
||||
max: Math.ceil(correctValue + tolerance),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTopGenreName(analytics: PartyAnalytics): string {
|
||||
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,24 +15,28 @@ export function buildSocialQuestion(
|
|||
members: PartyQuestionMember[],
|
||||
index: number,
|
||||
): Question {
|
||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||
const leader = getCurrentLeader(quizState, members);
|
||||
const diverse = getMostDiverseMember(analytics, members);
|
||||
const aligned = getMostAlignedMember(analytics, members);
|
||||
|
||||
const questions: Array<Omit<Question, "startTimestamp" | "endTimestamp">> = [
|
||||
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
|
||||
{
|
||||
type: "choice",
|
||||
text: "Who is leading the quiz right now?",
|
||||
options: buildMemberOptions(leader, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
},
|
||||
{
|
||||
type: "choice",
|
||||
text: "Who looks like the most diverse listener in the party?",
|
||||
options: buildMemberOptions(diverse, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
},
|
||||
{
|
||||
type: "choice",
|
||||
text: "Which member seems most aligned with the rest of the party?",
|
||||
options: buildMemberOptions(aligned, members),
|
||||
correct: 0,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { partyMember } from "../db/schema";
|
|||
import { generatePartyQuestion } from "../party/question-generator";
|
||||
import type { PartyAnalytics } from "../party/question-utils";
|
||||
import { updatePartyData } from "../party/state";
|
||||
import type { QuizResponse, QuizRound, QuizState } from "../party-types";
|
||||
import type { Question, QuizResponse, QuizRound, QuizState } from "../party-types";
|
||||
|
||||
const TOTAL_QUESTIONS = 5;
|
||||
|
||||
|
|
@ -95,24 +95,27 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
|
||||
receivedPlayers.add(response.playerId);
|
||||
const answeredAt = Date.now();
|
||||
const isCorrect = response.selected === question.correct;
|
||||
const pointsGained = isCorrect ? question.points : 0;
|
||||
const selectedValue = response.selected;
|
||||
const isCorrect = selectedValue === question.correct;
|
||||
const quizResponse: QuizResponse = {
|
||||
...response,
|
||||
selectedValue,
|
||||
correct: isCorrect,
|
||||
answeredAt,
|
||||
pointsGained,
|
||||
pointsGained: 0,
|
||||
};
|
||||
quizState.answers[response.playerId] = quizResponse;
|
||||
round.responses.push(quizResponse);
|
||||
|
||||
if (isCorrect) {
|
||||
quizState.scores[response.playerId] =
|
||||
(quizState.scores[response.playerId] ?? 0) + pointsGained;
|
||||
}
|
||||
|
||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
}
|
||||
|
||||
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
||||
quizState.scores[playerId] =
|
||||
(quizState.scores[playerId] ?? 0) + gained;
|
||||
}
|
||||
|
||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||
}
|
||||
|
||||
// Quiz complete
|
||||
|
|
@ -134,14 +137,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
partyId: string,
|
||||
quizState: QuizState,
|
||||
index: number,
|
||||
): Promise<{
|
||||
text: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
points: number;
|
||||
}> {
|
||||
): Promise<Question> {
|
||||
const partyRecord = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: partyId,
|
||||
|
|
@ -157,6 +153,46 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
});
|
||||
}
|
||||
|
||||
private static scoreRound(round: QuizRound): Array<[string, number]> {
|
||||
if (round.question.type !== "numeric") {
|
||||
return round.responses.map((response): [string, number] => [
|
||||
response.playerId,
|
||||
response.correct ? round.question.points : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
const ordered = round.responses
|
||||
.map((response) => ({
|
||||
response,
|
||||
distance: Math.abs((response.selectedValue ?? response.selected) - round.question.correct),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
const groups: Array<{ distance: number; responses: QuizResponse[] }> = [];
|
||||
for (const item of ordered) {
|
||||
const group = groups.at(-1);
|
||||
if (!group || group.distance !== item.distance) {
|
||||
groups.push({ distance: item.distance, responses: [item.response] });
|
||||
} else {
|
||||
group.responses.push(item.response);
|
||||
}
|
||||
}
|
||||
|
||||
const scoringGroups = groups.slice(0, Math.max(0, groups.length - 1));
|
||||
if (groups.length <= 1) {
|
||||
return round.responses.map((response) => [response.playerId, round.question.points]);
|
||||
}
|
||||
|
||||
return groups.flatMap((group, index) => {
|
||||
const factor = (groups.length - index - 1) / (groups.length - 1);
|
||||
const gained = Math.round(round.question.points * factor);
|
||||
return group.responses.map((response): [string, number] => [
|
||||
response.playerId,
|
||||
gained,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private static async getPartyMembers(
|
||||
partyId: string,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ProgressValue,
|
||||
} from "#/components/ui/progress";
|
||||
import { Section, SectionTitle } from "#/components/ui/section";
|
||||
import { Slider } from "#/components/ui/slider";
|
||||
import { Spinner } from "#/components/ui/spinner";
|
||||
import { useParty } from "#/hooks/use-party";
|
||||
import { useUser } from "#/hooks/user";
|
||||
|
|
@ -35,6 +36,7 @@ export function Question() {
|
|||
const { user } = useUser();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [selectedValue, setSelectedValue] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const question = party?.data?.currentQuestion;
|
||||
|
||||
|
|
@ -46,6 +48,12 @@ export function Question() {
|
|||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
setSelected(null);
|
||||
setSelectedValue(question.type === "numeric" ? question.range.min : null);
|
||||
}, [question]);
|
||||
|
||||
if (!question) return null;
|
||||
|
||||
const partyId = party.id;
|
||||
|
|
@ -54,6 +62,11 @@ export function Question() {
|
|||
const hasResponded = user ? party.data.answers[user.id] != null : false;
|
||||
const currentSelection =
|
||||
selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null);
|
||||
const currentNumericSelection =
|
||||
selectedValue ??
|
||||
(user ? (party.data.answers[user.id]?.selectedValue ?? null) : null);
|
||||
const numericMin = question.type === "numeric" ? question.range.min : 0;
|
||||
const numericMax = question.type === "numeric" ? question.range.max : 0;
|
||||
const progressValue = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
|
|
@ -64,7 +77,7 @@ export function Question() {
|
|||
),
|
||||
);
|
||||
|
||||
async function handleAnswer(optionIndex: number) {
|
||||
async function handleChoiceAnswer(optionIndex: number) {
|
||||
if (!partyId || hasResponded || isSubmitting) return;
|
||||
setSelected(optionIndex);
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -77,6 +90,20 @@ export function Question() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleNumericAnswer() {
|
||||
if (!partyId || hasResponded || isSubmitting) return;
|
||||
const value = selectedValue;
|
||||
if (value == null) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await client.api.party({ partyId }).quiz.response.post({
|
||||
selected: value,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
|
||||
|
|
@ -102,28 +129,66 @@ export function Question() {
|
|||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{question.options.map((option, index) => {
|
||||
const isSelected = currentSelection === index;
|
||||
return (
|
||||
<Item key={option} variant={isSelected ? "outline" : "default"}>
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<ItemTitle>
|
||||
{index + 1}. {option}
|
||||
</ItemTitle>
|
||||
{isSelected && <ItemDescription>Selected</ItemDescription>}
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
<Button
|
||||
variant={isSelected ? "secondary" : "default"}
|
||||
disabled={hasResponded || isSubmitting}
|
||||
onClick={() => handleAnswer(index)}
|
||||
>
|
||||
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
|
||||
</Button>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
{question.type === "numeric" ? (
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<ItemTitle>Choose a value</ItemTitle>
|
||||
<ItemDescription>
|
||||
Closest guesses get more points
|
||||
</ItemDescription>
|
||||
</ItemHeader>
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
min={numericMin}
|
||||
max={numericMax}
|
||||
step={1}
|
||||
value={
|
||||
currentNumericSelection != null
|
||||
? [currentNumericSelection]
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(value) => setSelectedValue(value[0] ?? null)}
|
||||
/>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Exact value:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{currentNumericSelection ?? question.correct}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
<Button
|
||||
disabled={hasResponded || isSubmitting || selectedValue == null}
|
||||
onClick={() => void handleNumericAnswer()}
|
||||
>
|
||||
{isSubmitting ? <Spinner /> : "Answer"}
|
||||
</Button>
|
||||
</Item>
|
||||
) : (
|
||||
question.options?.map((option, index) => {
|
||||
const isSelected = currentSelection === index;
|
||||
return (
|
||||
<Item key={option} variant={isSelected ? "outline" : "default"}>
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<ItemTitle>
|
||||
{index + 1}. {option}
|
||||
</ItemTitle>
|
||||
{isSelected && <ItemDescription>Selected</ItemDescription>}
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
<Button
|
||||
variant={isSelected ? "secondary" : "default"}
|
||||
disabled={hasResponded || isSubmitting}
|
||||
onClick={() => handleChoiceAnswer(index)}
|
||||
>
|
||||
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
|
||||
</Button>
|
||||
</Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ItemGroup>
|
||||
</Section>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue