Files
MineGeo/lib/tournament-api.ts
pavel444-byte 9c314ecb4e
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
Add tournament and multiplayer community chat moderation
2026-10-27 12:54:15 +05:00

262 lines
7.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/shared/database";
import {
tournamentEdit,
tournamentSettings,
type TournamentView,
} from "@/shared/tournaments";
import {
datasetSchema,
faces,
panoramaPath,
viewpointSchema,
} from "@/shared/protocol";
import {
chatMessageSchema,
roomGuessSchema,
roomRoundSchema,
} from "@/shared/multiplayer";
import { ServiceError, checkServiceError } from "./service-error";
type Dependencies = {
db: SupabaseClient<Database>;
account: () => Promise<{ id: string } | null>;
jsonBody: (request: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
mediaUrls: (paths: (string | null)[]) => Promise<(string | null)[]>;
announce: (target?: string) => void;
serverTimezone: string;
};
function unwrap<T>(result: {
data: T;
error: { message: string; code?: string } | null;
}): NonNullable<T> {
checkServiceError(result.error);
if (result.data == null) throw new ServiceError("Tournament not found", 404);
return result.data;
}
export async function tournamentApi(
request: NextRequest,
path: string[],
deps: Dependencies,
) {
const { db } = deps;
const respond = (value: unknown) =>
NextResponse.json(value, { headers: { "Cache-Control": "no-store" } });
if (path.length === 1 && request.method === "GET") {
const tournaments = unwrap(
await db
.from("tournaments")
.select(
"id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord,finished_at,winner_id",
)
.order("starts_at", { ascending: false })
.limit(100),
);
return respond({
tournaments,
serverNow: Date.now(),
serverTimezone: deps.serverTimezone,
});
}
if (path.length === 1 && request.method === "POST") {
const account = await deps.account();
if (!account) throw new ServiceError("Sign in to create a tournament", 401);
const body = tournamentSettings.parse(await deps.jsonBody(request));
const start = Date.parse(body.startsAt),
now = Date.now();
if (start <= now || start > now + 90 * 86400000)
throw new ServiceError(
"Schedule the tournament within the next 90 days",
400,
);
await deps.limit("tournaments:create:" + account.id, 5, 86400);
const id = unwrap(
await db.rpc("create_tournament", {
p_owner: account.id,
p_payload: { ...body, serverTimezone: deps.serverTimezone },
}),
);
deps.announce(String(id));
return respond({ id });
}
if (path.length < 2 || path.length > 3)
throw new ServiceError("Not found", 404);
const id = z.string().uuid().parse(path[1]);
const action = path[2] ?? "view";
if (["community", "chat"].includes(action)) {
if (!["GET", "POST"].includes(request.method))
throw new ServiceError("Not found", 404);
const account = await deps.account();
if (!account)
throw new ServiceError("Sign in to join or play a tournament", 401);
if (action === "chat") {
const body =
request.method === "POST"
? chatMessageSchema.strict().parse(await deps.jsonBody(request))
: null;
if (body) await deps.limit(`tournaments:chat:${account.id}`, 60, 60);
return respond(
unwrap(
await db.rpc("tournament_chat", {
p_tournament: id,
p_account: account.id,
p_text: body?.text ?? null,
p_client_id: body?.clientId ?? null,
}),
),
);
}
const schema = z.discriminatedUnion("action", [
z
.object({
action: z.literal("slow"),
seconds: z.number().int().min(0).max(300),
})
.strict(),
...(["admin", "unadmin", "kick", "ban", "unban"] as const).map((action) =>
z
.object({ action: z.literal(action), accountId: z.string().uuid() })
.strict(),
),
z
.object({
action: z.literal("tag"),
accountId: z.string().uuid(),
tag: z
.string()
.trim()
.max(32)
.refine(
(value) =>
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
value,
),
),
})
.strict(),
]);
const body =
request.method === "POST"
? schema.parse(await deps.jsonBody(request))
: { action: "view" as const };
if (request.method === "POST")
await deps.limit(`tournaments:moderation:${account.id}`, 30, 60);
return respond(
unwrap(
await db.rpc("tournament_community", {
p_tournament: id,
p_account: account.id,
p_action: body.action,
p_payload: body,
}),
),
);
}
const read =
request.method === "GET" && (action === "view" || action === "media");
const write =
request.method === "POST" &&
[
"join",
"leave",
"cancel",
"edit",
"start",
"ready",
"guess",
"next",
].includes(action);
if (!read && !write) throw new ServiceError("Not found", 404);
const account = await deps.account();
if (!account && (write || action === "media"))
throw new ServiceError("Sign in to join or play a tournament", 401);
let payload = {};
if (write) {
const body = await deps.jsonBody(request);
payload =
action === "guess"
? roomGuessSchema
.extend({ tournamentRound: z.number().int().min(0).max(9) })
.strict()
.parse(body)
: ["ready", "next"].includes(action)
? roomRoundSchema
.extend({ tournamentRound: z.number().int().min(0).max(9) })
.strict()
.parse(body)
: action === "edit"
? tournamentEdit.parse(body)
: z.object({}).strict().parse(body);
await deps.limit(
`tournaments:${action}:${account!.id}`,
action === "guess" || action === "ready" ? 180 : 30,
60,
);
}
const view = unwrap(
await db.rpc("tournament_action", {
p_tournament: id,
p_account: account?.id ?? null,
p_action: read ? "view" : action,
p_payload: payload,
}),
) as unknown as TournamentView;
if (write && action === "edit") deps.announce(id);
if (action !== "media") return respond(view);
const requestedRound = Number(
z
.string()
.regex(/^[0-9]$/)
.parse(request.nextUrl.searchParams.get("tournamentRound") ?? undefined),
);
if (requestedRound !== view.currentRound)
throw new ServiceError(
"The tournament advanced. Reconnect to load the current round.",
409,
);
const room = view.currentRoom;
if (
!room?.you ||
!room.id ||
!["loading", "playing", "results", "finished"].includes(room.status)
)
throw new ServiceError("This round is not available yet", 409);
const [record, point] = await Promise.all([
db
.from("multiplayer_rooms")
.select("revision_id")
.eq("id", room.id)
.single(),
db
.from("multiplayer_rounds")
.select("viewpoint")
.eq("room_id", room.id)
.eq("round_number", room.round)
.single(),
]);
const revision = unwrap(
await db
.from("revisions")
.select("asset_root,manifest")
.eq("id", unwrap(record).revision_id)
.single(),
);
const manifest = datasetSchema.parse(revision.manifest);
const viewpoint = viewpointSchema.parse(unwrap(point).viewpoint);
const signed = await deps.mediaUrls([
manifest.mapPath ?? `${revision.asset_root}/map.webp`,
...faces.map((face) =>
panoramaPath(revision.asset_root, viewpoint, `${face}.webp`),
),
]);
return respond({
round: room.round,
map: signed[0],
bounds: manifest.bounds,
faces: signed.slice(1),
});
}