Initial addition of the Arcade study feature. Add connections as the first working game.
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s

This commit is contained in:
Elijah 2026-07-13 17:35:18 -07:00
parent 7b90409f2e
commit 611a585757
43 changed files with 5990 additions and 68 deletions

View file

@ -10,6 +10,7 @@ export interface DailyActivity {
date: string;
flashcards: number;
questions: number;
arcade: number;
total: number;
level: 0 | 1 | 2 | 3 | 4;
}
@ -20,6 +21,7 @@ export interface ActivitySummary {
activeDays: number;
flashcards: number;
questions: number;
arcade: number;
currentStreak: number;
}
@ -46,7 +48,7 @@ function getLevel(total: number): 0 | 1 | 2 | 3 | 4 {
}
function emptyDay(date: string): DailyActivity {
return { date, flashcards: 0, questions: 0, total: 0, level: 0 };
return { date, flashcards: 0, questions: 0, arcade: 0, total: 0, level: 0 };
}
export async function recordActivity(type: StudyActivityType) {
@ -71,6 +73,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
const day = totals.get(date) ?? emptyDay(date);
if (activity.type === "FLASHCARD") day.flashcards += 1;
if (activity.type === "QUIZ_QUESTION") day.questions += 1;
if (activity.type === "ARCADE_GROUP") day.arcade += 1;
day.total += 1;
day.level = getLevel(day.total);
totals.set(date, day);
@ -83,6 +86,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
const visibleDays = days.filter((day) => day.date <= today && day.date <= endDate);
const flashcards = visibleDays.reduce((sum, day) => sum + day.flashcards, 0);
const questions = visibleDays.reduce((sum, day) => sum + day.questions, 0);
const arcade = visibleDays.reduce((sum, day) => sum + day.arcade, 0);
let streakDate = (totals.get(today)?.total ?? 0) >= STREAK_THRESHOLD
? today
@ -99,6 +103,7 @@ export async function getActivitySummary(now = new Date()): Promise<ActivitySumm
activeDays: visibleDays.filter((day) => day.total > 0).length,
flashcards,
questions,
arcade,
currentStreak,
};
}

View file

@ -0,0 +1,176 @@
import { prisma } from "@/lib/db";
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
import { replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
import type { ArcadeAttemptCreateInput } from "@/lib/validation/arcadeSchemas";
import type {
ArcadeAttemptSummary,
ArcadeGameKey,
ArcadePackSummary,
NormalizedConnectionsPack,
} from "@/types/arcade";
function attemptSummary(attempt: {
id: string;
score: number;
maxScore: number;
accuracy: number;
durationSeconds: number;
mistakes: number;
completedAt: Date;
resultsJson?: string;
}): ArcadeAttemptSummary {
return {
id: attempt.id,
score: attempt.score,
maxScore: attempt.maxScore,
accuracy: attempt.accuracy,
durationSeconds: attempt.durationSeconds,
mistakes: attempt.mistakes,
completedAt: attempt.completedAt.toISOString(),
...(attempt.resultsJson ? { results: JSON.parse(attempt.resultsJson) } : {}),
};
}
export function previewArcadeImport(rawJson: string) {
return parseConnectionsImportBatch(rawJson);
}
export async function createArcadePacks(input: {
classId: string;
gameType: ArcadeGameKey;
rawJson: string;
name?: string;
names?: string[];
warningsAcknowledged: boolean;
}) {
const classItem = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!classItem) throw new Error("Class not found");
const batch = parseConnectionsImportBatch(input.rawJson);
if (input.names && input.names.length !== batch.count) {
throw new Error("A name is required for every imported pack.");
}
if (batch.packs.some((preview) => preview.warnings.length > 0) && !input.warningsAcknowledged) {
throw new Error("Import warnings must be acknowledged before saving.");
}
const maxOrder = await prisma.arcadePack.aggregate({
where: { classId: input.classId, gameType: input.gameType },
_max: { sortOrder: true },
});
return prisma.$transaction(async (transaction) => {
const created = [];
for (const [index, preview] of batch.packs.entries()) {
created.push(await transaction.arcadePack.create({
data: {
classId: input.classId,
gameType: input.gameType,
name: input.names?.[index]?.trim() || (batch.count === 1 ? input.name?.trim() : undefined) || preview.name,
description: preview.description,
schemaVersion: preview.normalized.schemaVersion,
sourceJson: JSON.stringify(preview.normalized),
normalizedJson: JSON.stringify(preview.normalized),
validationReportJson: JSON.stringify({ wasRepaired: preview.wasRepaired, warnings: preview.warnings }),
sortOrder: (maxOrder._max.sortOrder ?? -1) + index + 1,
},
}));
}
return created;
});
}
export async function listArcadePacks(classId: string, gameType: ArcadeGameKey): Promise<ArcadePackSummary[]> {
const packs = await prisma.arcadePack.findMany({
where: { classId, gameType },
orderBy: { sortOrder: "asc" },
include: { attempts: { orderBy: { completedAt: "desc" } } },
});
return packs.map((pack) => {
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
return {
id: pack.id,
classId: pack.classId,
gameType: pack.gameType as ArcadeGameKey,
name: pack.name,
description: pack.description,
schemaVersion: pack.schemaVersion,
itemCount: normalized.content.length * 4,
defaultAllowedMistakes: normalized.settings.allowedMistakes,
bestScore: pack.attempts.length ? Math.max(...pack.attempts.map((attempt) => attempt.score)) : null,
latestAttempt: pack.attempts[0] ? attemptSummary(pack.attempts[0]) : null,
};
});
}
export async function getArcadePack(id: string) {
const pack = await prisma.arcadePack.findUnique({
where: { id },
include: { class: { select: { slug: true, name: true } } },
});
if (!pack) return null;
return {
...pack,
gameType: pack.gameType as ArcadeGameKey,
normalized: JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack,
validationReport: JSON.parse(pack.validationReportJson),
};
}
export async function updateArcadePack(id: string, name: string) {
const existing = await prisma.arcadePack.findUnique({ where: { id }, select: { id: true } });
if (!existing) return null;
return prisma.arcadePack.update({ where: { id }, data: { name } });
}
export async function deleteArcadePack(id: string) {
const existing = await prisma.arcadePack.findUnique({ where: { id }, select: { id: true } });
if (!existing) return false;
await prisma.arcadePack.delete({ where: { id } });
return true;
}
export async function listArcadeAttempts(arcadePackId: string) {
const attempts = await prisma.arcadeAttempt.findMany({
where: { arcadePackId },
orderBy: { completedAt: "desc" },
take: 20,
});
return attempts.map(attemptSummary);
}
export async function createArcadeAttempt(arcadePackId: string, input: ArcadeAttemptCreateInput) {
const pack = await prisma.arcadePack.findUnique({ where: { id: arcadePackId } });
if (!pack) return null;
const normalized = JSON.parse(pack.normalizedJson) as NormalizedConnectionsPack;
const replay = replayConnectionsAttempt(
normalized,
input.submissions,
input.settings,
input.durationSeconds
);
if (!replay.complete) throw new Error("Only completed Connections rounds can be saved.");
const solvedGroups = replay.result.groups.filter((group) => group.solved).length;
return prisma.$transaction(async (transaction) => {
const attempt = await transaction.arcadeAttempt.create({
data: {
arcadePackId,
mode: input.mode,
score: replay.result.score,
maxScore: replay.result.maxScore,
accuracy: replay.result.accuracy,
durationSeconds: replay.result.durationSeconds,
mistakes: replay.result.mistakes,
hintsUsed: replay.result.hintsUsed,
settingsJson: JSON.stringify(input.settings),
resultsJson: JSON.stringify(replay.result),
seed: input.seed,
},
});
if (solvedGroups > 0) {
await transaction.studyActivity.createMany({
data: Array.from({ length: solvedGroups }, () => ({ type: "ARCADE_GROUP" })),
});
}
return attemptSummary({ ...attempt, resultsJson: attempt.resultsJson });
});
}

View file

@ -1,4 +1,7 @@
import { prisma } from "@/lib/db";
import { ARCADE_SERVER_REGISTRY } from "@/lib/arcade/registry";
export type LlmInstructionType = "flashcards" | "quizzes" | "connections";
const DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS = `You are generating study materials in a strict JSON format for import into a
personal study app. The overall output must be raw JSON with no wrapping
@ -48,9 +51,14 @@ Rules:
Material:
[paste your notes or lecture content here]`;
export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promise<string> {
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
function instructionConfig(type: LlmInstructionType) {
if (type === "flashcards") return { key: "llmInstructionsFlashcards", value: DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS };
if (type === "quizzes") return { key: "llmInstructionsQuizzes", value: DEFAULT_LLM_INSTRUCTIONS_QUIZZES };
return { key: "llmInstructionsConnections", value: ARCADE_SERVER_REGISTRY.connections.defaultInstructions };
}
export async function getLlmInstructions(type: LlmInstructionType): Promise<string> {
const { key, value: def } = instructionConfig(type);
const setting = await prisma.setting.findUnique({
where: { key },
@ -58,8 +66,8 @@ export async function getLlmInstructions(type: "flashcards" | "quizzes"): Promis
return setting?.value ?? def;
}
export async function updateLlmInstructions(type: "flashcards" | "quizzes", value: string) {
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
export async function updateLlmInstructions(type: LlmInstructionType, value: string) {
const { key } = instructionConfig(type);
return prisma.setting.upsert({
where: { key },
@ -68,9 +76,8 @@ export async function updateLlmInstructions(type: "flashcards" | "quizzes", valu
});
}
export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
const key = type === "flashcards" ? "llmInstructionsFlashcards" : "llmInstructionsQuizzes";
const def = type === "flashcards" ? DEFAULT_LLM_INSTRUCTIONS_FLASHCARDS : DEFAULT_LLM_INSTRUCTIONS_QUIZZES;
export async function resetLlmInstructions(type: LlmInstructionType) {
const { key, value: def } = instructionConfig(type);
return prisma.setting.upsert({
where: { key },
@ -78,4 +85,3 @@ export async function resetLlmInstructions(type: "flashcards" | "quizzes") {
create: { key, value: def },
});
}