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
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m4s
This commit is contained in:
parent
7b90409f2e
commit
611a585757
43 changed files with 5990 additions and 68 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
176
src/services/arcadeService.ts
Normal file
176
src/services/arcadeService.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue