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
63
src/lib/arcade/connectionsEngine.test.ts
Normal file
63
src/lib/arcade/connectionsEngine.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { deterministicShuffle, replayConnectionsAttempt } from "@/lib/arcade/connectionsEngine";
|
||||
import type { NormalizedConnectionsPack } from "@/types/arcade";
|
||||
|
||||
const pack: NormalizedConnectionsPack = {
|
||||
schemaVersion: 1,
|
||||
type: "connections",
|
||||
name: "Test",
|
||||
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
|
||||
content: Array.from({ length: 4 }, (_, groupIndex) => ({
|
||||
id: `group-${groupIndex + 1}`,
|
||||
category: `Category ${groupIndex + 1}`,
|
||||
explanation: `Explanation ${groupIndex + 1}`,
|
||||
items: Array.from({ length: 4 }, (_, itemIndex) => ({
|
||||
id: `g${groupIndex + 1}-i${itemIndex + 1}`,
|
||||
text: `Item ${groupIndex + 1}-${itemIndex + 1}`,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const settings = { allowedMistakes: 4, oneAwayFeedback: true };
|
||||
|
||||
describe("Connections engine", () => {
|
||||
it("shuffles deterministically for a seed", () => {
|
||||
const items = Array.from({ length: 16 }, (_, index) => index);
|
||||
expect(deterministicShuffle(items, "round-1")).toEqual(deterministicShuffle(items, "round-1"));
|
||||
expect(deterministicShuffle(items, "round-1")).not.toEqual(deterministicShuffle(items, "round-2"));
|
||||
});
|
||||
|
||||
it("scores a perfect completed round", () => {
|
||||
const submissions = pack.content.map((group, index) => ({
|
||||
itemIds: group.items.map((item) => item.id),
|
||||
elapsedMs: (index + 1) * 1000,
|
||||
}));
|
||||
const replay = replayConnectionsAttempt(pack, submissions, settings, 12);
|
||||
expect(replay.complete).toBe(true);
|
||||
expect(replay.result).toMatchObject({ outcome: "WON", score: 400, mistakes: 0, accuracy: 1 });
|
||||
});
|
||||
|
||||
it("deducts mistakes and detects one-away selections", () => {
|
||||
const wrong = [
|
||||
...pack.content[0].items.slice(0, 3).map((item) => item.id),
|
||||
pack.content[1].items[0].id,
|
||||
];
|
||||
const submissions = [
|
||||
{ itemIds: wrong, elapsedMs: 1000 },
|
||||
...pack.content.map((group, index) => ({ itemIds: group.items.map((item) => item.id), elapsedMs: (index + 2) * 1000 })),
|
||||
];
|
||||
const replay = replayConnectionsAttempt(pack, submissions, settings, 20);
|
||||
expect(replay.result).toMatchObject({ outcome: "WON", score: 390, mistakes: 1, hintsUsed: 1, accuracy: 0.8 });
|
||||
});
|
||||
|
||||
it("ends after the configured number of mistakes", () => {
|
||||
const wrong = [pack.content[0].items[0].id, pack.content[1].items[0].id, pack.content[2].items[0].id, pack.content[3].items[0].id];
|
||||
const replay = replayConnectionsAttempt(pack, Array.from({ length: 4 }, (_, index) => ({ itemIds: wrong, elapsedMs: index * 1000 })), settings, 10);
|
||||
expect(replay.complete).toBe(true);
|
||||
expect(replay.result).toMatchObject({ outcome: "LOST", score: 0, mistakes: 4 });
|
||||
});
|
||||
|
||||
it("rejects selections containing unknown item ids", () => {
|
||||
expect(() => replayConnectionsAttempt(pack, [{ itemIds: ["missing", "a", "b", "c"], elapsedMs: 0 }], settings, 0)).toThrow("invalid tile selection");
|
||||
});
|
||||
});
|
||||
110
src/lib/arcade/connectionsEngine.ts
Normal file
110
src/lib/arcade/connectionsEngine.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type {
|
||||
ArcadeRoundResult,
|
||||
ArcadeSessionSettings,
|
||||
ConnectionsSubmission,
|
||||
NormalizedConnectionsGroup,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
function hashSeed(seed: string) {
|
||||
let value = 2166136261;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
value ^= seed.charCodeAt(index);
|
||||
value = Math.imul(value, 16777619);
|
||||
}
|
||||
return value >>> 0;
|
||||
}
|
||||
|
||||
function seededRandom(seed: string) {
|
||||
let state = hashSeed(seed);
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function deterministicShuffle<T>(items: readonly T[], seed: string): T[] {
|
||||
const result = [...items];
|
||||
const random = seededRandom(seed);
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(random() * (index + 1));
|
||||
[result[index], result[swapIndex]] = [result[swapIndex], result[index]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupForSelection(groups: NormalizedConnectionsGroup[], itemIds: string[]) {
|
||||
const selected = new Set(itemIds);
|
||||
return groups.find(
|
||||
(group) => group.items.length === selected.size && group.items.every((item) => selected.has(item.id))
|
||||
);
|
||||
}
|
||||
|
||||
export function isOneAway(
|
||||
groups: NormalizedConnectionsGroup[],
|
||||
selectedItemIds: string[],
|
||||
solvedGroupIds: Set<string>
|
||||
) {
|
||||
const selected = new Set(selectedItemIds);
|
||||
return groups.some(
|
||||
(group) =>
|
||||
!solvedGroupIds.has(group.id) &&
|
||||
group.items.filter((item) => selected.has(item.id)).length === 3
|
||||
);
|
||||
}
|
||||
|
||||
export function replayConnectionsAttempt(
|
||||
pack: NormalizedConnectionsPack,
|
||||
submissions: ConnectionsSubmission[],
|
||||
settings: ArcadeSessionSettings,
|
||||
durationSeconds: number
|
||||
): { complete: boolean; result: ArcadeRoundResult } {
|
||||
const validItemIds = new Set(pack.content.flatMap((group) => group.items.map((item) => item.id)));
|
||||
const solvedGroupIds = new Set<string>();
|
||||
const incorrectSelections: string[][] = [];
|
||||
let hintsUsed = 0;
|
||||
let mistakes = 0;
|
||||
let processed = 0;
|
||||
|
||||
for (const submission of submissions) {
|
||||
if (solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes) break;
|
||||
if (new Set(submission.itemIds).size !== 4 || submission.itemIds.some((id) => !validItemIds.has(id))) {
|
||||
throw new Error("An attempt contains an invalid tile selection.");
|
||||
}
|
||||
processed += 1;
|
||||
const group = groupForSelection(pack.content, submission.itemIds);
|
||||
if (group && !solvedGroupIds.has(group.id)) {
|
||||
solvedGroupIds.add(group.id);
|
||||
} else {
|
||||
if (settings.oneAwayFeedback && isOneAway(pack.content, submission.itemIds, solvedGroupIds)) {
|
||||
hintsUsed += 1;
|
||||
}
|
||||
mistakes += 1;
|
||||
incorrectSelections.push([...submission.itemIds]);
|
||||
}
|
||||
}
|
||||
|
||||
const complete = solvedGroupIds.size === pack.content.length || mistakes >= settings.allowedMistakes;
|
||||
const score = Math.max(0, solvedGroupIds.size * 100 - mistakes * 10);
|
||||
const result: ArcadeRoundResult = {
|
||||
outcome: solvedGroupIds.size === pack.content.length ? "WON" : "LOST",
|
||||
score,
|
||||
maxScore: 400,
|
||||
accuracy: processed === 0 ? 0 : solvedGroupIds.size / processed,
|
||||
durationSeconds,
|
||||
mistakes,
|
||||
hintsUsed,
|
||||
groups: pack.content.map((group) => ({
|
||||
groupId: group.id,
|
||||
category: group.category,
|
||||
items: group.items.map((item) => item.text),
|
||||
explanation: group.explanation,
|
||||
solved: solvedGroupIds.has(group.id),
|
||||
})),
|
||||
incorrectSelections,
|
||||
};
|
||||
return { complete, result };
|
||||
}
|
||||
74
src/lib/arcade/connectionsImport.test.ts
Normal file
74
src/lib/arcade/connectionsImport.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ArcadeImportError, parseConnectionsImport, parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
|
||||
function validPack() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: "connections",
|
||||
name: "Medication Connections",
|
||||
description: "Four medication groups",
|
||||
settings: { groupCount: 4, itemsPerGroup: 4, allowedMistakes: 4 },
|
||||
content: Array.from({ length: 4 }, (_, groupIndex) => ({
|
||||
category: `Category ${groupIndex + 1}`,
|
||||
items: Array.from({ length: 4 }, (_, itemIndex) => `Item ${groupIndex + 1}-${itemIndex + 1}`),
|
||||
explanation: `Explanation ${groupIndex + 1}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Connections imports", () => {
|
||||
it("normalizes a valid board and creates stable ids", () => {
|
||||
const result = parseConnectionsImport(JSON.stringify(validPack()));
|
||||
expect(result.preview.itemCount).toBe(16);
|
||||
expect(result.preview.itemCount).toBe(16);
|
||||
expect(result.preview.normalized.content[0].id).toBe("group-1");
|
||||
expect(result.preview.normalized.content[0].items[0].id).toBe("group-1-item-1");
|
||||
});
|
||||
|
||||
it("rejects fences and surrounding prose", () => {
|
||||
expect(() => parseConnectionsImport(`\`\`\`json\n${JSON.stringify(validPack())}\n\`\`\``)).toThrow(ArcadeImportError);
|
||||
expect(() => parseConnectionsImport(`Here is the pack: ${JSON.stringify(validPack())}`)).toThrow(ArcadeImportError);
|
||||
});
|
||||
|
||||
it("rejects duplicate tiles case-insensitively", () => {
|
||||
const pack = validPack();
|
||||
pack.content[1].items[0] = " item 1-1 ";
|
||||
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("Every tile must be unique");
|
||||
});
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
const pack = { ...validPack(), extra: true };
|
||||
expect(() => parseConnectionsImport(JSON.stringify(pack))).toThrow("does not match schema version 1");
|
||||
});
|
||||
|
||||
it("reports long-tile warnings without changing the content", () => {
|
||||
const pack = validPack();
|
||||
pack.content[0].items[0] = "This intentionally long tile contains too many words";
|
||||
const result = parseConnectionsImport(JSON.stringify(pack));
|
||||
expect(result.report.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.preview.normalized.content[0].items[0].text).toBe(pack.content[0].items[0]);
|
||||
});
|
||||
|
||||
it("does not treat a shared generic word as a similar category", () => {
|
||||
const pack = validPack();
|
||||
pack.content[0].category = "ACE Inhibitors";
|
||||
pack.content[1].category = "Proton Pump Inhibitors";
|
||||
const result = parseConnectionsImport(JSON.stringify(pack));
|
||||
expect(result.report.warnings).not.toContain(
|
||||
"Categories “ACE Inhibitors” and “Proton Pump Inhibitors” may be too similar."
|
||||
);
|
||||
});
|
||||
|
||||
it("previews multiple packs from one JSON array", () => {
|
||||
const first = validPack();
|
||||
const second = { ...validPack(), name: "Second board" };
|
||||
const result = parseConnectionsImportBatch(JSON.stringify([first, second]));
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.packs.map((pack) => pack.name)).toEqual(["Medication Connections", "Second board"]);
|
||||
});
|
||||
|
||||
it("limits batch imports to ten packs", () => {
|
||||
const batch = Array.from({ length: 11 }, () => validPack());
|
||||
expect(() => parseConnectionsImportBatch(JSON.stringify(batch))).toThrow("between 1 and 10");
|
||||
});
|
||||
});
|
||||
170
src/lib/arcade/connectionsImport.ts
Normal file
170
src/lib/arcade/connectionsImport.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { parseAndRepairJson } from "@/lib/jsonRepair";
|
||||
import { connectionsImportSchema } from "@/lib/validation/arcadeSchemas";
|
||||
import type {
|
||||
ArcadeImportPreview,
|
||||
ArcadeImportBatchPreview,
|
||||
ArcadeValidationReport,
|
||||
NormalizedConnectionsPack,
|
||||
} from "@/types/arcade";
|
||||
|
||||
export class ArcadeImportError extends Error {
|
||||
constructor(message: string, public readonly details: string[] = []) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function clean(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function comparisonKey(value: string) {
|
||||
return clean(value).toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function safeId(value: string, fallback: string) {
|
||||
const id = value
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
return id || fallback;
|
||||
}
|
||||
|
||||
function findWarnings(pack: NormalizedConnectionsPack) {
|
||||
const warnings: string[] = [];
|
||||
const categoryKeys = pack.content.map((group) => comparisonKey(group.category));
|
||||
|
||||
for (const group of pack.content) {
|
||||
for (const item of group.items) {
|
||||
if (item.text.length > 40) {
|
||||
warnings.push(`“${item.text}” is longer than 40 characters and may be difficult to scan.`);
|
||||
} else if (item.text.split(/\s+/).length > 4) {
|
||||
warnings.push(`“${item.text}” contains more than four words.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let left = 0; left < categoryKeys.length; left += 1) {
|
||||
for (let right = left + 1; right < categoryKeys.length; right += 1) {
|
||||
const a = categoryKeys[left];
|
||||
const b = categoryKeys[right];
|
||||
if (a.includes(b) || b.includes(a)) {
|
||||
warnings.push(
|
||||
`Categories “${pack.content[left].category}” and “${pack.content[right].category}” may be too similar.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(warnings)];
|
||||
}
|
||||
|
||||
export function parseConnectionsImportBatch(rawJson: string): ArcadeImportBatchPreview {
|
||||
const trimmed = rawJson.trim();
|
||||
const isObject = trimmed.startsWith("{") && trimmed.endsWith("}");
|
||||
const isArray = trimmed.startsWith("[") && trimmed.endsWith("]");
|
||||
if (trimmed.includes("```") || (!isObject && !isArray)) {
|
||||
throw new ArcadeImportError("Paste one raw JSON object or an array of pack objects without Markdown fences or surrounding prose.");
|
||||
}
|
||||
|
||||
const repaired = parseAndRepairJson(trimmed);
|
||||
if (!repaired.success) {
|
||||
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
|
||||
}
|
||||
const values = Array.isArray(repaired.data) ? repaired.data : [repaired.data];
|
||||
if (values.length < 1 || values.length > 10) {
|
||||
throw new ArcadeImportError("Import between 1 and 10 Connections packs at a time.");
|
||||
}
|
||||
|
||||
const packs = values.map((value, index) => {
|
||||
try {
|
||||
const parsed = parseConnectionsImport(JSON.stringify(value));
|
||||
return {
|
||||
...parsed.preview,
|
||||
wasRepaired: parsed.preview.wasRepaired || repaired.wasRepaired,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ArcadeImportError) {
|
||||
throw new ArcadeImportError(`Pack ${index + 1}: ${error.message}`, error.details);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return { packs, count: packs.length, wasRepaired: repaired.wasRepaired };
|
||||
}
|
||||
|
||||
export function parseConnectionsImport(rawJson: string): {
|
||||
preview: ArcadeImportPreview;
|
||||
report: ArcadeValidationReport;
|
||||
} {
|
||||
const trimmed = rawJson.trim();
|
||||
if (trimmed.includes("```") || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
throw new ArcadeImportError("Paste one raw JSON object without Markdown fences or surrounding prose.");
|
||||
}
|
||||
|
||||
const repaired = parseAndRepairJson(trimmed);
|
||||
if (!repaired.success) {
|
||||
throw new ArcadeImportError(repaired.error ?? "The JSON could not be parsed.");
|
||||
}
|
||||
|
||||
const parsed = connectionsImportSchema.safeParse(repaired.data);
|
||||
if (!parsed.success) {
|
||||
throw new ArcadeImportError(
|
||||
"The Connections pack does not match schema version 1.",
|
||||
parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
const categoryKeys = parsed.data.content.map((group) => comparisonKey(group.category));
|
||||
if (new Set(categoryKeys).size !== categoryKeys.length) {
|
||||
throw new ArcadeImportError("Every group must have a unique category name.");
|
||||
}
|
||||
|
||||
const itemKeys = parsed.data.content.flatMap((group) => group.items.map(comparisonKey));
|
||||
if (new Set(itemKeys).size !== itemKeys.length) {
|
||||
throw new ArcadeImportError("Every tile must be unique across the entire board.");
|
||||
}
|
||||
|
||||
const usedGroupIds = new Set<string>();
|
||||
const normalized: NormalizedConnectionsPack = {
|
||||
schemaVersion: 1,
|
||||
type: "connections",
|
||||
name: clean(parsed.data.name),
|
||||
description: parsed.data.description ? clean(parsed.data.description) : undefined,
|
||||
settings: { ...parsed.data.settings },
|
||||
content: parsed.data.content.map((group, groupIndex) => {
|
||||
const fallbackId = `group-${groupIndex + 1}`;
|
||||
const groupId = safeId(group.id ?? fallbackId, fallbackId);
|
||||
if (usedGroupIds.has(groupId)) {
|
||||
throw new ArcadeImportError(`Group id “${groupId}” is duplicated.`);
|
||||
}
|
||||
usedGroupIds.add(groupId);
|
||||
return {
|
||||
id: groupId,
|
||||
category: clean(group.category),
|
||||
explanation: group.explanation.trim(),
|
||||
items: group.items.map((text, itemIndex) => ({
|
||||
id: `${groupId}-item-${itemIndex + 1}`,
|
||||
text: clean(text),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const warnings = findWarnings(normalized);
|
||||
const report = { wasRepaired: repaired.wasRepaired, warnings };
|
||||
return {
|
||||
report,
|
||||
preview: {
|
||||
gameType: "connections",
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
categories: normalized.content.map((group) => group.category),
|
||||
itemCount: 16,
|
||||
wasRepaired: repaired.wasRepaired,
|
||||
warnings,
|
||||
normalized,
|
||||
},
|
||||
};
|
||||
}
|
||||
35
src/lib/arcade/registry.ts
Normal file
35
src/lib/arcade/registry.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { parseConnectionsImportBatch } from "@/lib/arcade/connectionsImport";
|
||||
import type { ArcadeGameKey } from "@/types/arcade";
|
||||
|
||||
const CONNECTIONS_PROMPT = `You are generating a Connections study game as strict JSON.
|
||||
Return exactly one JSON object with no Markdown fence or commentary.
|
||||
|
||||
Use this schema:
|
||||
{"schemaVersion":1,"type":"connections","name":"...","description":"...","settings":{"groupCount":4,"itemsPerGroup":4,"allowedMistakes":4},"content":[{"id":"group-1","category":"...","items":["...","...","...","..."],"explanation":"..."}]}
|
||||
|
||||
Rules:
|
||||
- Generate exactly four groups of exactly four unique items.
|
||||
- Every item must fit only its intended group within this board.
|
||||
- Use specific, distinct category names and concise tiles of one to four words.
|
||||
- Give every group a concise explanation.
|
||||
- Do not create arbitrary leftover groups or use broad categories shared by most tiles.
|
||||
- Base all content strictly on the study material below.
|
||||
|
||||
Material:
|
||||
[paste your notes or lecture content here]`;
|
||||
|
||||
export const ARCADE_SERVER_REGISTRY = {
|
||||
connections: {
|
||||
schemaVersion: 1,
|
||||
preview: parseConnectionsImportBatch,
|
||||
defaultInstructions: CONNECTIONS_PROMPT,
|
||||
},
|
||||
} satisfies Record<ArcadeGameKey, {
|
||||
schemaVersion: number;
|
||||
preview: typeof parseConnectionsImportBatch;
|
||||
defaultInstructions: string;
|
||||
}>;
|
||||
|
||||
export function getArcadeGameDefinition(gameType: ArcadeGameKey) {
|
||||
return ARCADE_SERVER_REGISTRY[gameType];
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const studyActivitySchema = z.object({
|
||||
type: z.enum(["FLASHCARD", "QUIZ_QUESTION"]),
|
||||
type: z.enum(["FLASHCARD", "QUIZ_QUESTION", "ARCADE_GROUP"]),
|
||||
});
|
||||
|
||||
export type StudyActivityType = z.infer<typeof studyActivitySchema>["type"];
|
||||
|
|
|
|||
61
src/lib/validation/arcadeSchemas.ts
Normal file
61
src/lib/validation/arcadeSchemas.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const connectionGroupSchema = z.object({
|
||||
id: z.string().trim().min(1).optional(),
|
||||
category: z.string().trim().min(1).max(100),
|
||||
items: z.tuple([
|
||||
z.string().trim().min(1),
|
||||
z.string().trim().min(1),
|
||||
z.string().trim().min(1),
|
||||
z.string().trim().min(1),
|
||||
]),
|
||||
explanation: z.string().trim().min(1).max(1000),
|
||||
// Accepted for compatibility with early packs, but no longer used or generated.
|
||||
difficulty: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
||||
}).strict();
|
||||
|
||||
export const connectionsImportSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
type: z.literal("connections"),
|
||||
name: z.string().trim().min(1).max(150),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
settings: z.object({
|
||||
groupCount: z.literal(4),
|
||||
itemsPerGroup: z.literal(4),
|
||||
allowedMistakes: z.number().int().min(1).max(8),
|
||||
}).strict(),
|
||||
content: z.array(connectionGroupSchema).length(4),
|
||||
}).strict();
|
||||
|
||||
export const arcadePreviewRequestSchema = z.object({
|
||||
gameType: z.literal("connections"),
|
||||
rawJson: z.string().min(1),
|
||||
});
|
||||
|
||||
export const arcadePackCreateSchema = arcadePreviewRequestSchema.extend({
|
||||
classId: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(150).optional(),
|
||||
names: z.array(z.string().trim().min(1).max(150)).min(1).max(10).optional(),
|
||||
warningsAcknowledged: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const arcadePackUpdateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(150),
|
||||
});
|
||||
|
||||
export const arcadeAttemptCreateSchema = z.object({
|
||||
mode: z.literal("CLASSIC").default("CLASSIC"),
|
||||
durationSeconds: z.number().int().min(0).max(86400),
|
||||
seed: z.string().min(1).max(100),
|
||||
settings: z.object({
|
||||
allowedMistakes: z.number().int().min(1).max(8),
|
||||
oneAwayFeedback: z.boolean(),
|
||||
}).strict(),
|
||||
submissions: z.array(z.object({
|
||||
itemIds: z.array(z.string().min(1)).length(4),
|
||||
elapsedMs: z.number().int().min(0).max(86400000),
|
||||
}).strict()).max(50),
|
||||
});
|
||||
|
||||
export type ConnectionsImportInput = z.infer<typeof connectionsImportSchema>;
|
||||
export type ArcadeAttemptCreateInput = z.infer<typeof arcadeAttemptCreateSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue