Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
188
lib/tournament-api.ts
Normal file
188
lib/tournament-api.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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 { 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";
|
||||
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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user